diff --git a/internal/service/service.go b/internal/service/service.go
index d199066..851320e 100644
--- a/internal/service/service.go
+++ b/internal/service/service.go
@@ -446,6 +446,8 @@ type Request struct {
Model string `json:"model,omitempty"`
// SystemMessage overrides the configured system message for this request.
SystemMessage string `json:"system_message,omitempty"`
+ // MaxIterations caps the number of agent loop turns (0 = unlimited).
+ MaxIterations int `json:"max_iterations,omitempty"`
}
// Response is the response format for chat and voice endpoints.
@@ -541,7 +543,7 @@ func (svc *Service) chat(w http.ResponseWriter, r *http.Request) {
}
res, err := llmc.Completions(
- ctx, model, req.SystemMessage, 0, req.Messages,
+ ctx, model, req.SystemMessage, req.MaxIterations, req.Messages,
)
if err != nil {
log.ErrorContext(
@@ -713,7 +715,7 @@ func (svc *Service) chatStream(w http.ResponseWriter, r *http.Request) {
)
go func() {
errs <- llmc.CompletionsStream(
- ctx, model, req.SystemMessage, 0, req.Messages, events,
+ ctx, model, req.SystemMessage, req.MaxIterations, req.Messages, events,
)
close(errs)
}()
diff --git a/internal/service/static/icons.svg b/internal/service/static/icons.svg
index 94c1a2b..f1bdf43 100644
--- a/internal/service/static/icons.svg
+++ b/internal/service/static/icons.svg
@@ -134,4 +134,10 @@
+
+
+
+
+
+
diff --git a/internal/service/static/main.css b/internal/service/static/main.css
index 6018b83..dce9059 100644
--- a/internal/service/static/main.css
+++ b/internal/service/static/main.css
@@ -661,6 +661,18 @@ body {
}
}
+#compose-settings-close {
+ border: none;
+ color: var(--color-text);
+}
+
+@media (hover: hover) {
+ #compose-settings-close:hover {
+ border-color: transparent;
+ color: var(--color-text);
+ }
+}
+
/* Tabs */
.settings-tabs {
display: flex;
@@ -715,6 +727,14 @@ body {
margin-bottom: var(--s-2);
}
+.settings-label__sub {
+ display: block;
+ font-size: var(--s-2);
+ font-weight: 400;
+ color: var(--color-text-muted);
+ margin-top: 0.125rem;
+}
+
.settings-select {
width: 100%;
height: var(--s2);
@@ -735,6 +755,10 @@ body {
outline-offset: 2px;
}
+.settings-field {
+ margin-bottom: var(--s1);
+}
+
.settings-textarea {
display: block;
width: 100%;
diff --git a/internal/service/static/main.js b/internal/service/static/main.js
index 465632c..cf2bc7e 100644
--- a/internal/service/static/main.js
+++ b/internal/service/static/main.js
@@ -14,6 +14,7 @@ import { WakeLock } from './wakelock.js';
const STREAM_ENDPOINT = '/v1/chat/voice/stream';
const SYSTEM_MESSAGE_KEY = 'odidere_system_message';
+const MAX_TURNS_KEY = 'odidere_max_turns';
const ICONS_URL = '/static/icons.svg';
const StreamEventDelta = 'delta';
@@ -102,6 +103,13 @@ class Odidere {
this.$settingsClose = document.getElementById('settings-close');
this.$settingsTabs = document.querySelectorAll('.settings-tab');
this.$saveSystemMessage = document.getElementById('save-system-message');
+ this.$composeSettingsOverlay = document.getElementById(
+ 'compose-settings-overlay',
+ );
+ this.$composeSettingsClose = document.getElementById(
+ 'compose-settings-close',
+ );
+ this.$maxTurnsInput = document.getElementById('max-turns-input');
this.$compose = document.querySelector('.compose');
this.init();
@@ -241,6 +249,18 @@ class Odidere {
this.settings.saveSystemMessage(),
);
+ // --- Compose settings ---
+ this.$composeSettingsClose.addEventListener('click', () =>
+ this._closeComposeSettings(),
+ );
+ this.$composeSettingsOverlay.addEventListener('click', (e) => {
+ if (e.target === this.$composeSettingsOverlay)
+ this._closeComposeSettings();
+ });
+ this.$maxTurnsInput.addEventListener('input', () => {
+ this._setMaxTurns(this.$maxTurnsInput.value);
+ });
+
// --- Model/voice change persistence ---
this.settings.$model.addEventListener('change', () =>
this.settings.saveModelSelection(),
@@ -297,6 +317,9 @@ class Odidere {
case 'settings':
this.settings.open();
break;
+ case 'compose-settings':
+ this._openComposeSettings();
+ break;
case 'attach':
// Handled by direct listener on $attach.
break;
@@ -599,6 +622,10 @@ class Odidere {
if (systemMessage) {
payload.system_message = systemMessage;
}
+ const maxTurns = this._getMaxTurns();
+ if (maxTurns > 0) {
+ payload.max_iterations = maxTurns;
+ }
// Clear attachments and location after building payload.
this.clearAttachments();
@@ -1100,6 +1127,30 @@ class Odidere {
this.$location.classList.remove('compose__action-btn--location', 'active');
}
+ // ====================
+ // COMPOSE SETTINGS
+ // ====================
+
+ _openComposeSettings() {
+ this.$maxTurnsInput.value = this._getMaxTurns();
+ this.$composeSettingsOverlay.classList.add('open');
+ this.$composeSettingsClose.focus();
+ }
+
+ _closeComposeSettings() {
+ this.$composeSettingsOverlay.classList.remove('open');
+ this.$composeSettings.focus();
+ }
+
+ _getMaxTurns() {
+ const stored = localStorage.getItem(MAX_TURNS_KEY);
+ return stored ? Number(stored) : 0;
+ }
+
+ _setMaxTurns(value) {
+ localStorage.setItem(MAX_TURNS_KEY, String(value));
+ }
+
// ====================
// TWO-PRESS SAFETY PATTERNS
// ====================
diff --git a/internal/service/templates/static/body.gohtml b/internal/service/templates/static/body.gohtml
index 7fd95ea..f5bf196 100644
--- a/internal/service/templates/static/body.gohtml
+++ b/internal/service/templates/static/body.gohtml
@@ -4,5 +4,6 @@
{{ template "templates" . }}
{{ template "modal/settings" . }}
{{ template "modal/edit" . }}
+ {{ template "modal/compose-settings" . }}