detached 85630b6

feat(tui): generate session terminal titles

commit
85630b638d12132387c4478bb617ecc65ee1e660
author
brice · · 2026-08-04 11:46 UTC
committer
brice · · 2026-08-04 12:30 UTC
parent
0d7cbb3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
16 files · +1310 -49
M README.md +4 -3
@@ -290,7 +290,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu
290290 | `/unjail` | Allow tools to touch paths outside again. |
291291 | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). |
292292 | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. |
293-| `/settings` | Change persistent settings, including inline images, terminal alerts, auto-swarm, fast mode, and the auto-compact threshold. Saved to `$ZOT_HOME/config.json`; takes effect immediately. |
293+| `/settings` | Change persistent settings, including inline images, terminal alerts, AI terminal titles, auto-swarm, fast mode, and the auto-compact threshold. Saved to `$ZOT_HOME/config.json`; setting changes apply without a restart, while AI title generation waits for the first real prompt. |
294294 | `/clear` | Clear the chat transcript. |
295295 | `/exit` | Exit zot. |
296296
@@ -302,7 +302,7 @@ Type `!` followed by a command to run it directly without going through the mode
302302
303303 ### `/sessions`
304304
305-Shows previous sessions for the current working directory, newest first, with timestamp, model, message count, cost, and the first user prompt. Pick one with `up`/`down`, `enter` to resume, `esc` to cancel. zot swaps the current session file for the selected one and replays the full transcript (including tool calls) into the agent. Sessions remember their stored provider and model: resuming rebuilds the live agent when necessary instead of silently using a different model; if that rebuild fails, the current session is left unchanged. Branches created for tree navigation remain available to `/session tree` but are hidden from this flat picker.
305+Shows previous sessions for the current working directory, newest first, with timestamp, model, message count, cost, and the session title. Pick one with `up`/`down`, `enter` to resume, `esc` to cancel. zot swaps the current session file for the selected one and replays the full transcript (including tool calls) into the agent. Sessions remember their stored provider and model: resuming rebuilds the live agent when necessary instead of silently using a different model; if that rebuild fails, the current session is left unchanged. Branches created for tree navigation remain available to `/session tree` but are hidden from this flat picker. A fresh interactive session asks the active model for a concise title after the first real prompt and restores that title when resumed.
306306
307307 ### `/session`
308308
@@ -392,10 +392,11 @@ zot discovers the common markdown/frontmatter profile layout from `~/.agents/age
392392
393393 ### `/settings`
394394
395-Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` or `space` to change the selected row, `esc` to close (rows that open a sub-view, like model shortcuts, use `esc` to go back one level first). Changes are written to `$ZOT_HOME/config.json` and take effect on the next turn (no restart needed). Current settings:
395+Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` or `space` to change the selected row, `esc` to close (rows that open a sub-view, like model shortcuts, use `esc` to go back one level first). Changes are written to `$ZOT_HOME/config.json` without a restart; individual settings document whether they affect the current turn, the next model call, or a later session event. Current settings:
396396
397397 - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol.
398398 - **terminal alerts** — emit a terminal bell when the main session stops with work that may need attention, or when an extension raises a structured alert. Enabled by default; changes apply immediately and persist as `terminal_alerts_enabled`. Terminal emulators may render the bell audibly, visually, or not at all.
399+- **AI terminal titles** — after the first real prompt of a fresh interactive session, make one small hidden request to the active model and set the terminal title to `zot: <title>`. The title is limited to 40 Unicode characters, persisted with the session, restored on resume, and never added to the conversation. Enabled by default; disable it to avoid the extra model request. The toggle applies immediately, but title generation still waits for the first real prompt and never starts from startup context, resumed history, or slash commands. Persists as `terminal_title_enabled`.
399400 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and available named profiles are rendered in `[subagents_list]`. The tool accepts a profile name through `agent` and a per-child `reasoning`/`thinking` override. zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` and [docs/subagents.md](docs/subagents.md) for details.
400401 - **fast mode** — request OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; changes apply on the next model call and persist as `fast_mode`. Other providers return an unsupported-provider error. Fast mode may cost more and depends on the selected OpenAI model/account.
401402 - **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses.
@@ -290,7 +290,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu
290 | `/unjail` | Allow tools to touch paths outside again. |290 | `/unjail` | Allow tools to touch paths outside again. |
291 | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). |291 | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). |
292 | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. |292 | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. |
293-| `/settings` | Change persistent settings, including inline images, terminal alerts, auto-swarm, fast mode, and the auto-compact threshold. Saved to `$ZOT_HOME/config.json`; takes effect immediately. |293+| `/settings` | Change persistent settings, including inline images, terminal alerts, AI terminal titles, auto-swarm, fast mode, and the auto-compact threshold. Saved to `$ZOT_HOME/config.json`; setting changes apply without a restart, while AI title generation waits for the first real prompt. |
294 | `/clear` | Clear the chat transcript. |294 | `/clear` | Clear the chat transcript. |
295 | `/exit` | Exit zot. |295 | `/exit` | Exit zot. |
296 296
@@ -302,7 +302,7 @@ Type `!` followed by a command to run it directly without going through the mode
302 302
303 ### `/sessions`303 ### `/sessions`
304 304
305-Shows previous sessions for the current working directory, newest first, with timestamp, model, message count, cost, and the first user prompt. Pick one with `up`/`down`, `enter` to resume, `esc` to cancel. zot swaps the current session file for the selected one and replays the full transcript (including tool calls) into the agent. Sessions remember their stored provider and model: resuming rebuilds the live agent when necessary instead of silently using a different model; if that rebuild fails, the current session is left unchanged. Branches created for tree navigation remain available to `/session tree` but are hidden from this flat picker.305+Shows previous sessions for the current working directory, newest first, with timestamp, model, message count, cost, and the session title. Pick one with `up`/`down`, `enter` to resume, `esc` to cancel. zot swaps the current session file for the selected one and replays the full transcript (including tool calls) into the agent. Sessions remember their stored provider and model: resuming rebuilds the live agent when necessary instead of silently using a different model; if that rebuild fails, the current session is left unchanged. Branches created for tree navigation remain available to `/session tree` but are hidden from this flat picker. A fresh interactive session asks the active model for a concise title after the first real prompt and restores that title when resumed.
306 306
307 ### `/session`307 ### `/session`
308 308
@@ -392,10 +392,11 @@ zot discovers the common markdown/frontmatter profile layout from `~/.agents/age
392 392
393 ### `/settings`393 ### `/settings`
394 394
395-Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` or `space` to change the selected row, `esc` to close (rows that open a sub-view, like model shortcuts, use `esc` to go back one level first). Changes are written to `$ZOT_HOME/config.json` and take effect on the next turn (no restart needed). Current settings:395+Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` or `space` to change the selected row, `esc` to close (rows that open a sub-view, like model shortcuts, use `esc` to go back one level first). Changes are written to `$ZOT_HOME/config.json` without a restart; individual settings document whether they affect the current turn, the next model call, or a later session event. Current settings:
396 396
397 - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol.397 - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol.
398 - **terminal alerts** — emit a terminal bell when the main session stops with work that may need attention, or when an extension raises a structured alert. Enabled by default; changes apply immediately and persist as `terminal_alerts_enabled`. Terminal emulators may render the bell audibly, visually, or not at all.398 - **terminal alerts** — emit a terminal bell when the main session stops with work that may need attention, or when an extension raises a structured alert. Enabled by default; changes apply immediately and persist as `terminal_alerts_enabled`. Terminal emulators may render the bell audibly, visually, or not at all.
399+- **AI terminal titles** — after the first real prompt of a fresh interactive session, make one small hidden request to the active model and set the terminal title to `zot: <title>`. The title is limited to 40 Unicode characters, persisted with the session, restored on resume, and never added to the conversation. Enabled by default; disable it to avoid the extra model request. The toggle applies immediately, but title generation still waits for the first real prompt and never starts from startup context, resumed history, or slash commands. Persists as `terminal_title_enabled`.
399 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and available named profiles are rendered in `[subagents_list]`. The tool accepts a profile name through `agent` and a per-child `reasoning`/`thinking` override. zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` and [docs/subagents.md](docs/subagents.md) for details.400 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and available named profiles are rendered in `[subagents_list]`. The tool accepts a profile name through `agent` and a per-child `reasoning`/`thinking` override. zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` and [docs/subagents.md](docs/subagents.md) for details.
400 - **fast mode** — request OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; changes apply on the next model call and persist as `fast_mode`. Other providers return an unsupported-provider error. Fast mode may cost more and depends on the selected OpenAI model/account.401 - **fast mode** — request OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; changes apply on the next model call and persist as `fast_mode`. Other providers return an unsupported-provider error. Fast mode may cost more and depends on the selected OpenAI model/account.
401 - **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses.402 - **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses.
M packages/agent/cli.go +65 -5
@@ -1231,8 +1231,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
12311231
12321232 var sess *core.Session
12331233 var sessBaselineMsgs int // messages already on disk when current session opened
1234- // persistMu also guards sess + sessBaselineMsgs, so a session swap
1235- // commits the transcript writer and live selection under the same lock.
1234+ var sessionTitlePending bool
12361235 if !args.NoSess && ag != nil {
12371236 var sessErr error
12381237 sess, sessErr = openOrCreateSession(args, r, ag, version)
@@ -1258,6 +1257,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
12581257 r.Provider = candidate.provider
12591258 r.Model = candidate.model
12601259 }
1260+ if sess != nil && ag != nil {
1261+ sessionTitlePending = sess.Meta.Parent != "" && sess.Title == "" && len(ag.Messages()) <= sess.Meta.ForkPoint
1262+ }
12611263 }
12621264 defer func() {
12631265 persistMu.Lock()
@@ -1402,11 +1404,11 @@ func runInteractive(ctx context.Context, args Args, version string) error {
14021404 persistMu.Unlock()
14031405 return fmt.Errorf("session changed while loading; please try again")
14041406 }
1405-
14061407 if oldSess != nil {
14071408 _ = oldSess.Close()
14081409 }
14091410 sess = candidate.session
1411+ sessionTitlePending = candidate.session != nil && candidate.session.Meta.Parent != "" && candidate.session.Title == "" && candidate.fullMessageCount <= candidate.session.Meta.ForkPoint
14101412 // The live agent only receives a compact resume window, but the
14111413 // session file remains intact. Keep the persistence baseline at
14121414 // the original on-disk message count so future turns append after
@@ -1575,6 +1577,14 @@ func runInteractive(ctx context.Context, args Args, version string) error {
15751577 }
15761578 }
15771579
1580+ // Invalidate any title request before swapping the host-owned session
1581+ // pointer. The interactive state reset below happens after the swap,
1582+ // so this guard closes the window where a late result could otherwise
1583+ // be written to the new session.
1584+ if iv != nil {
1585+ iv.CancelPendingSessionTitle()
1586+ }
1587+
15781588 // Commit only after every fallible operation has succeeded. Close
15791589 // the old session last so rollback never has to resurrect it.
15801590 persistMu.Lock()
@@ -1584,6 +1594,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
15841594 }
15851595 sess = newSess
15861596 sessBaselineMsgs = 0
1597+ sessionTitlePending = false
15871598 activeProvider = newProvider
15881599 activeModel = newModel
15891600 persistMu.Unlock()
@@ -1693,6 +1704,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
16931704 Theme: theme,
16941705 InlineImagesEnabled: initialCfg.InlineImagesEnabled,
16951706 TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled,
1707+ TerminalTitleEnabled: initialCfg.TerminalTitleEnabled,
16961708 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
16971709 FastMode: &fastMode,
16981710 AutoCompactThreshold: initialCfg.AutoCompactThreshold,
@@ -1732,8 +1744,15 @@ func runInteractive(ctx context.Context, args Args, version string) error {
17321744 UpdateInfoChan: updateCh,
17331745 Sandbox: sharedSandbox,
17341746 Agent: ag,
1735- InitialInput: args.Prompt,
1736- StartupPre: args.StartupPre,
1747+ InitialSessionTitle: func() string {
1748+ if sess == nil || sessionTitlePending {
1749+ return ""
1750+ }
1751+ return sess.Title
1752+ }(),
1753+ InitialSessionTitlePending: sessionTitlePending,
1754+ InitialInput: args.Prompt,
1755+ StartupPre: args.StartupPre,
17371756 OnStartupPreDone: func() {
17381757 // entry.pre often installs skills/extensions; rediscover them
17391758 // before the user starts a model turn.
@@ -1852,6 +1871,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
18521871 // selection used by future resumes. The live agent has already
18531872 // switched pairs, so keep the in-memory selection aligned even
18541873 // when the append fails and report that persistence error below.
1874+ sessionTransitionMu.RLock()
1875+ defer sessionTransitionMu.RUnlock()
18551876 persistMu.Lock()
18561877 var sessionErr error
18571878 if sess != nil {
@@ -1881,6 +1902,45 @@ func runInteractive(ctx context.Context, args Args, version string) error {
18811902 iv.Notify("session", "error", "model changed for this run, but config could not be saved: "+cfgErr.Error())
18821903 }
18831904 },
1905+ CurrentSessionTitle: func() string {
1906+ sessionTransitionMu.RLock()
1907+ defer sessionTransitionMu.RUnlock()
1908+ persistMu.Lock()
1909+ defer persistMu.Unlock()
1910+ if sess == nil || sessionTitlePending {
1911+ return ""
1912+ }
1913+ return sess.Title
1914+ },
1915+ CurrentSessionTitlePending: func() bool {
1916+ sessionTransitionMu.RLock()
1917+ defer sessionTransitionMu.RUnlock()
1918+ persistMu.Lock()
1919+ defer persistMu.Unlock()
1920+ return sessionTitlePending
1921+ },
1922+ PersistTitle: func(title string) error {
1923+ persistMu.Lock()
1924+ defer persistMu.Unlock()
1925+ if sess == nil {
1926+ return nil
1927+ }
1928+ if err := sess.UpdateTitle(title); err != nil {
1929+ return err
1930+ }
1931+ sessionTitlePending = false
1932+ return nil
1933+ },
1934+ OnSessionTitleChanged: func(title string) {
1935+ sessionTransitionMu.RLock()
1936+ defer sessionTransitionMu.RUnlock()
1937+ persistMu.Lock()
1938+ defer persistMu.Unlock()
1939+ if sess != nil {
1940+ sess.Title = title
1941+ sessionTitlePending = false
1942+ }
1943+ },
18841944 })
18851945 extHooks.attachInteractive(iv)
18861946
@@ -1231,8 +1231,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1231 1231
1232 var sess *core.Session1232 var sess *core.Session
1233 var sessBaselineMsgs int // messages already on disk when current session opened1233 var sessBaselineMsgs int // messages already on disk when current session opened
1234- // persistMu also guards sess + sessBaselineMsgs, so a session swap1234+ var sessionTitlePending bool
1235- // commits the transcript writer and live selection under the same lock.
1236 if !args.NoSess && ag != nil {1235 if !args.NoSess && ag != nil {
1237 var sessErr error1236 var sessErr error
1238 sess, sessErr = openOrCreateSession(args, r, ag, version)1237 sess, sessErr = openOrCreateSession(args, r, ag, version)
@@ -1258,6 +1257,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1258 r.Provider = candidate.provider1257 r.Provider = candidate.provider
1259 r.Model = candidate.model1258 r.Model = candidate.model
1260 }1259 }
1260+ if sess != nil && ag != nil {
1261+ sessionTitlePending = sess.Meta.Parent != "" && sess.Title == "" && len(ag.Messages()) <= sess.Meta.ForkPoint
1262+ }
1261 }1263 }
1262 defer func() {1264 defer func() {
1263 persistMu.Lock()1265 persistMu.Lock()
@@ -1402,11 +1404,11 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1402 persistMu.Unlock()1404 persistMu.Unlock()
1403 return fmt.Errorf("session changed while loading; please try again")1405 return fmt.Errorf("session changed while loading; please try again")
1404 }1406 }
1405-
1406 if oldSess != nil {1407 if oldSess != nil {
1407 _ = oldSess.Close()1408 _ = oldSess.Close()
1408 }1409 }
1409 sess = candidate.session1410 sess = candidate.session
1411+ sessionTitlePending = candidate.session != nil && candidate.session.Meta.Parent != "" && candidate.session.Title == "" && candidate.fullMessageCount <= candidate.session.Meta.ForkPoint
1410 // The live agent only receives a compact resume window, but the1412 // The live agent only receives a compact resume window, but the
1411 // session file remains intact. Keep the persistence baseline at1413 // session file remains intact. Keep the persistence baseline at
1412 // the original on-disk message count so future turns append after1414 // the original on-disk message count so future turns append after
@@ -1575,6 +1577,14 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1575 }1577 }
1576 }1578 }
1577 1579
1580+ // Invalidate any title request before swapping the host-owned session
1581+ // pointer. The interactive state reset below happens after the swap,
1582+ // so this guard closes the window where a late result could otherwise
1583+ // be written to the new session.
1584+ if iv != nil {
1585+ iv.CancelPendingSessionTitle()
1586+ }
1587+
1578 // Commit only after every fallible operation has succeeded. Close1588 // Commit only after every fallible operation has succeeded. Close
1579 // the old session last so rollback never has to resurrect it.1589 // the old session last so rollback never has to resurrect it.
1580 persistMu.Lock()1590 persistMu.Lock()
@@ -1584,6 +1594,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1584 }1594 }
1585 sess = newSess1595 sess = newSess
1586 sessBaselineMsgs = 01596 sessBaselineMsgs = 0
1597+ sessionTitlePending = false
1587 activeProvider = newProvider1598 activeProvider = newProvider
1588 activeModel = newModel1599 activeModel = newModel
1589 persistMu.Unlock()1600 persistMu.Unlock()
@@ -1693,6 +1704,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1693 Theme: theme,1704 Theme: theme,
1694 InlineImagesEnabled: initialCfg.InlineImagesEnabled,1705 InlineImagesEnabled: initialCfg.InlineImagesEnabled,
1695 TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled,1706 TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled,
1707+ TerminalTitleEnabled: initialCfg.TerminalTitleEnabled,
1696 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,1708 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
1697 FastMode: &fastMode,1709 FastMode: &fastMode,
1698 AutoCompactThreshold: initialCfg.AutoCompactThreshold,1710 AutoCompactThreshold: initialCfg.AutoCompactThreshold,
@@ -1732,8 +1744,15 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1732 UpdateInfoChan: updateCh,1744 UpdateInfoChan: updateCh,
1733 Sandbox: sharedSandbox,1745 Sandbox: sharedSandbox,
1734 Agent: ag,1746 Agent: ag,
1735- InitialInput: args.Prompt,1747+ InitialSessionTitle: func() string {
1736- StartupPre: args.StartupPre,1748+ if sess == nil || sessionTitlePending {
1749+ return ""
1750+ }
1751+ return sess.Title
1752+ }(),
1753+ InitialSessionTitlePending: sessionTitlePending,
1754+ InitialInput: args.Prompt,
1755+ StartupPre: args.StartupPre,
1737 OnStartupPreDone: func() {1756 OnStartupPreDone: func() {
1738 // entry.pre often installs skills/extensions; rediscover them1757 // entry.pre often installs skills/extensions; rediscover them
1739 // before the user starts a model turn.1758 // before the user starts a model turn.
@@ -1852,6 +1871,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1852 // selection used by future resumes. The live agent has already1871 // selection used by future resumes. The live agent has already
1853 // switched pairs, so keep the in-memory selection aligned even1872 // switched pairs, so keep the in-memory selection aligned even
1854 // when the append fails and report that persistence error below.1873 // when the append fails and report that persistence error below.
1874+ sessionTransitionMu.RLock()
1875+ defer sessionTransitionMu.RUnlock()
1855 persistMu.Lock()1876 persistMu.Lock()
1856 var sessionErr error1877 var sessionErr error
1857 if sess != nil {1878 if sess != nil {
@@ -1881,6 +1902,45 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1881 iv.Notify("session", "error", "model changed for this run, but config could not be saved: "+cfgErr.Error())1902 iv.Notify("session", "error", "model changed for this run, but config could not be saved: "+cfgErr.Error())
1882 }1903 }
1883 },1904 },
1905+ CurrentSessionTitle: func() string {
1906+ sessionTransitionMu.RLock()
1907+ defer sessionTransitionMu.RUnlock()
1908+ persistMu.Lock()
1909+ defer persistMu.Unlock()
1910+ if sess == nil || sessionTitlePending {
1911+ return ""
1912+ }
1913+ return sess.Title
1914+ },
1915+ CurrentSessionTitlePending: func() bool {
1916+ sessionTransitionMu.RLock()
1917+ defer sessionTransitionMu.RUnlock()
1918+ persistMu.Lock()
1919+ defer persistMu.Unlock()
1920+ return sessionTitlePending
1921+ },
1922+ PersistTitle: func(title string) error {
1923+ persistMu.Lock()
1924+ defer persistMu.Unlock()
1925+ if sess == nil {
1926+ return nil
1927+ }
1928+ if err := sess.UpdateTitle(title); err != nil {
1929+ return err
1930+ }
1931+ sessionTitlePending = false
1932+ return nil
1933+ },
1934+ OnSessionTitleChanged: func(title string) {
1935+ sessionTransitionMu.RLock()
1936+ defer sessionTransitionMu.RUnlock()
1937+ persistMu.Lock()
1938+ defer persistMu.Unlock()
1939+ if sess != nil {
1940+ sess.Title = title
1941+ sessionTitlePending = false
1942+ }
1943+ },
1884 })1944 })
1885 extHooks.attachInteractive(iv)1945 extHooks.attachInteractive(iv)
1886 1946
M packages/agent/config.go +5 -0
@@ -63,6 +63,11 @@ type Config struct {
6363 // alerts. Toggle from /settings.
6464 TerminalAlertsEnabled *bool `json:"terminal_alerts_enabled,omitempty"`
6565
66+ // TerminalTitleEnabled controls the hidden title request and OSC 0 title
67+ // updates in interactive mode. nil/missing means enabled; false disables
68+ // both so no extra provider request is made. Toggle from /settings.
69+ TerminalTitleEnabled *bool `json:"terminal_title_enabled,omitempty"`
70+
6671 // AutoSwarmEnabled lets the main agent spawn background sub-agents
6772 // for parallel sub-tasks via a built-in swarm_spawn tool. Off by
6873 // default; nil/missing means disabled. Toggle from /settings.
@@ -63,6 +63,11 @@ type Config struct {
63 // alerts. Toggle from /settings.63 // alerts. Toggle from /settings.
64 TerminalAlertsEnabled *bool `json:"terminal_alerts_enabled,omitempty"`64 TerminalAlertsEnabled *bool `json:"terminal_alerts_enabled,omitempty"`
65 65
66+ // TerminalTitleEnabled controls the hidden title request and OSC 0 title
67+ // updates in interactive mode. nil/missing means enabled; false disables
68+ // both so no extra provider request is made. Toggle from /settings.
69+ TerminalTitleEnabled *bool `json:"terminal_title_enabled,omitempty"`
70+
66 // AutoSwarmEnabled lets the main agent spawn background sub-agents71 // AutoSwarmEnabled lets the main agent spawn background sub-agents
67 // for parallel sub-tasks via a built-in swarm_spawn tool. Off by72 // for parallel sub-tasks via a built-in swarm_spawn tool. Off by
68 // default; nil/missing means disabled. Toggle from /settings.73 // default; nil/missing means disabled. Toggle from /settings.
M packages/agent/modes/interactive.go +153 -4
@@ -63,6 +63,10 @@ type InteractiveConfig struct {
6363 // agent and extensions. nil means enabled; false disables them.
6464 TerminalAlertsEnabled *bool
6565
66+ // TerminalTitleEnabled controls the hidden title request and terminal
67+ // title updates. nil means enabled; false disables both.
68+ TerminalTitleEnabled *bool
69+
6670 // AutoSwarmEnabled mirrors the persisted config flag at startup so
6771 // the /settings dialog can render the current state without
6872 // re-reading config.json on every open.
@@ -267,6 +271,29 @@ type InteractiveConfig struct {
267271 // write a new meta row so resume picks up the same model.
268272 PersistModel func(providerName, model string)
269273
274+ // InitialSessionTitle is the persisted title of the session loaded before
275+ // the TUI starts. It is shown in the terminal without another model call.
276+ InitialSessionTitle string
277+ // InitialSessionTitlePending is true for a newly forked branch that only
278+ // contains its copied prefix. The copied user messages must not prevent a
279+ // new title after the branch's first real prompt.
280+ InitialSessionTitlePending bool
281+
282+ // CurrentSessionTitle returns the title of the session currently owned by
283+ // the host. It is used after /sessions and import switches.
284+ CurrentSessionTitle func() string
285+ // CurrentSessionTitlePending reports whether the loaded session is a new
286+ // branch that has not received a post-fork prompt yet.
287+ CurrentSessionTitlePending func() bool
288+
289+ // PersistTitle stores an automatically generated title without adding a
290+ // user or assistant message to the transcript. It returns persistence
291+ // errors without affecting the main turn.
292+ PersistTitle func(title string) error
293+ // OnSessionTitleChanged updates host-side in-memory title bookkeeping
294+ // after a manual rename has already been written to disk.
295+ OnSessionTitleChanged func(title string)
296+
270297 OnAssistant func(m provider.Message)
271298 OnToolResult func(id string, r core.ToolResult)
272299
@@ -373,6 +400,10 @@ type terminalAlertsSettingsStore interface {
373400 SetTerminalAlertsEnabled(enabled bool) error
374401 }
375402
403+type terminalTitleSettingsStore interface {
404+ SetTerminalTitleEnabled(enabled bool) error
405+}
406+
376407 type showInstructionsSettingsStore interface {
377408 SetShowInstructionsAtStartup(enabled bool) error
378409 }
@@ -569,6 +600,18 @@ type Interactive struct {
569600 // after this point and reverts to plain text after.
570601 welcomeStart time.Time
571602
603+ // interactiveStarted gates session-title generation so tests and
604+ // embedders that call Submit before Run do not issue a hidden request.
605+ interactiveStarted bool
606+ // titleRealPromptSeen prevents a fresh session from generating more than
607+ // one title. It is reset only when a fork or a new cwd starts a new branch.
608+ titleRealPromptSeen bool
609+ titleGenerationStarted bool
610+ firstRealPrompt string
611+ titleVersion uint64
612+ titleCancel context.CancelFunc
613+ sessionTitle string
614+
572615 // extNotes are one-shot styled lines pushed by extensions via
573616 // Notify / Display. They live above the editor (just below the
574617 // transcript) until cleared by /clear or another reset.
@@ -721,6 +764,15 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
721764 i.view.TailLimit = initialResumeTailLimit
722765 }
723766 }
767+ i.sessionTitle = core.NormalizeSessionTitle(cfg.InitialSessionTitle)
768+ if cfg.InitialSessionTitlePending {
769+ i.sessionTitle = ""
770+ i.titleRealPromptSeen = false
771+ i.titleGenerationStarted = false
772+ } else {
773+ i.titleRealPromptSeen = i.sessionTitle != "" || hasRealUserPrompt(i.view.Messages)
774+ i.titleGenerationStarted = i.titleRealPromptSeen
775+ }
724776 if cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled {
725777 i.applyAutoSwarmTool(true)
726778 }
@@ -736,6 +788,8 @@ func (i *Interactive) Run(ctx context.Context) error {
736788 return err
737789 }
738790 defer restore()
791+ i.markInteractiveStarted()
792+ defer i.cancelSessionTitle()
739793 defer func() {
740794 if i.telegramBridge != nil {
741795 i.telegramBridge.Stop()
@@ -2254,10 +2308,29 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
22542308 i.sessionDialog.Close()
22552309 return false
22562310 }
2311+ manualRenameCurrent := false
2312+ if k.Kind == tui.KeyEnter && i.sessionDialog.renaming && core.NormalizeSessionTitle(i.sessionDialog.rename) != "" && i.sessionDialog.cursor >= 0 && i.sessionDialog.cursor < len(i.sessionDialog.sessions) && i.cfg.CurrentSessionPath != nil {
2313+ path := i.sessionDialog.sessions[i.sessionDialog.cursor].Path
2314+ manualRenameCurrent = i.cfg.CurrentSessionPath() == path
2315+ if manualRenameCurrent {
2316+ i.markSessionTitleSwitching()
2317+ }
2318+ }
22572319 act := i.sessionDialog.HandleKey(k)
22582320 if act.Select {
22592321 i.applySessionSelection(act.Path)
22602322 }
2323+ if act.Err != nil {
2324+ if manualRenameCurrent {
2325+ i.restoreFailedSessionTitle()
2326+ }
2327+ i.mu.Lock()
2328+ i.statusErr = "rename: " + act.Err.Error()
2329+ i.statusOK = ""
2330+ i.mu.Unlock()
2331+ } else if act.Renamed && act.Path != "" && i.cfg.CurrentSessionPath != nil && i.cfg.CurrentSessionPath() == act.Path {
2332+ i.setManualSessionTitle(act.RenameTitle)
2333+ }
22612334 // Always request a redraw after handling a key here: when esc
22622335 // closes the picker, the overlay-close detection in the render
22632336 // pass needs to run so the tall dialog rows get repainted from
@@ -2836,6 +2909,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
28362909 if i.telegramBridge != nil && i.telegramBridge.Active() {
28372910 go i.telegramBridge.OnUserTyped(text)
28382911 }
2912+ i.maybeStartSessionTitle(ctx, text)
28392913 // If a turn is already in flight, queue this prompt inside the
28402914 // agent loop so it is delivered at the next safe model-call
28412915 // boundary instead of waiting for the whole run to finish.
@@ -3145,7 +3219,17 @@ func (i *Interactive) Submit(text string) {
31453219 i.startShellEscape(i.runCtx, cmd)
31463220 return
31473221 }
3148- i.startTurn(i.runCtx, text)
3222+ parent := i.runCtx
3223+ if parent == nil {
3224+ parent = context.Background()
3225+ }
3226+ i.mu.Lock()
3227+ awaitingStartupPre := i.awaitingStartupPre
3228+ i.mu.Unlock()
3229+ if !awaitingStartupPre {
3230+ i.maybeStartSessionTitle(parent, text)
3231+ }
3232+ i.startTurn(parent, text)
31493233 }
31503234
31513235 // completeStartupPre applies deferred InitialInput after entry.pre finishes.
@@ -3259,6 +3343,13 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin
32593343 }
32603344 i.cfg.Provider = provider
32613345 i.cfg.Model = model
3346+ titleCancel := i.titleCancel
3347+ i.titleCancel = nil
3348+ i.titleVersion++
3349+ i.sessionTitle = ""
3350+ i.titleRealPromptSeen = false
3351+ i.titleGenerationStarted = false
3352+ i.writeTerminalTitleLocked("")
32623353 i.toolCalls = map[string]*tui.ToolCallView{}
32633354 i.toolOrder = nil
32643355 i.toolGate = map[string]int{}
@@ -3266,6 +3357,9 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin
32663357 i.parkedTurn = 0
32673358 i.statusErr = ""
32683359 i.mu.Unlock()
3360+ if titleCancel != nil {
3361+ titleCancel()
3362+ }
32693363 i.fileSuggest.Reset()
32703364 i.fileSuggest.SetCWD(cwd)
32713365 i.invalidate()
@@ -3312,6 +3406,9 @@ func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
33123406 i.invalidate()
33133407 return
33143408 }
3409+ i.mu.Unlock()
3410+ i.maybeStartSessionTitle(i.runCtx, text)
3411+ i.mu.Lock()
33153412 if i.busy {
33163413 // Queue text only; images are dropped for queued prompts. Compaction
33173414 // uses the host queue because it has no active agent loop to drain
@@ -3513,6 +3610,7 @@ func (i *Interactive) openSettingsDialog() {
35133610 }
35143611
35153612 terminalAlerts := terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled)
3613+ terminalTitles := terminalTitleEnabled(i.cfg.TerminalTitleEnabled)
35163614 autoSwarm := false
35173615 if i.cfg.AutoSwarmEnabled != nil {
35183616 autoSwarm = *i.cfg.AutoSwarmEnabled
@@ -3634,6 +3732,12 @@ func (i *Interactive) openSettingsDialog() {
36343732 desc: "ring the terminal when the main session needs attention or an extension raises an alert",
36353733 value: terminalAlerts,
36363734 },
3735+ {
3736+ key: "terminal_title_enabled",
3737+ label: "AI terminal titles",
3738+ desc: "ask the active model for a concise title after the first prompt; uses one extra hidden request",
3739+ value: terminalTitles,
3740+ },
36373741 {
36383742 key: "auto_swarm_enabled",
36393743 label: "auto-swarm",
@@ -4005,6 +4109,35 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
40054109 i.statusOK = "terminal alerts " + onOff(value)
40064110 i.statusErr = ""
40074111 i.mu.Unlock()
4112+ case "terminal_title_enabled":
4113+ val := value
4114+ var cancel context.CancelFunc
4115+ i.mu.Lock()
4116+ i.cfg.TerminalTitleEnabled = &val
4117+ if value {
4118+ i.writeTerminalTitleLocked(i.sessionTitle)
4119+ } else {
4120+ cancel = i.titleCancel
4121+ i.titleCancel = nil
4122+ i.titleVersion++
4123+ i.writeTerminalTitleLocked("")
4124+ }
4125+ i.mu.Unlock()
4126+ if cancel != nil {
4127+ cancel()
4128+ }
4129+ if store, ok := i.cfg.SettingsStore.(terminalTitleSettingsStore); ok {
4130+ if err := store.SetTerminalTitleEnabled(value); err != nil {
4131+ i.mu.Lock()
4132+ i.statusErr = "settings: " + err.Error()
4133+ i.mu.Unlock()
4134+ return
4135+ }
4136+ }
4137+ i.mu.Lock()
4138+ i.statusOK = "AI terminal titles " + onOff(value)
4139+ i.statusErr = ""
4140+ i.mu.Unlock()
40084141 case "auto_swarm_enabled":
40094142 val := value
40104143 i.cfg.AutoSwarmEnabled = &val
@@ -5367,18 +5500,23 @@ func (i *Interactive) applySessionSelection(path string) {
53675500 i.statusErr = ""
53685501 i.mu.Unlock()
53695502 i.invalidate()
5503+ i.markSessionTitleSwitching()
53705504
53715505 go func() {
53725506 err := i.cfg.LoadSession(path)
5373- i.mu.Lock()
5374- defer i.mu.Unlock()
5375- i.sessionLoading = false
53765507 if err != nil {
5508+ i.restoreFailedSessionTitle()
5509+ i.mu.Lock()
5510+ i.sessionLoading = false
53775511 i.statusErr = err.Error()
53785512 i.statusOK = ""
5513+ i.mu.Unlock()
53795514 i.invalidate()
53805515 return
53815516 }
5517+ i.restoreLoadedSessionTitle()
5518+ i.mu.Lock()
5519+ i.sessionLoading = false
53825520 i.statusOK = "resumed session: " + path
53835521 i.statusErr = ""
53845522 i.parkedTurn = 0
@@ -5408,6 +5546,7 @@ func (i *Interactive) applySessionSelection(path string) {
54085546 i.view.TailLimit = 0
54095547 }
54105548 }
5549+ i.mu.Unlock()
54115550 i.invalidate()
54125551 }()
54135552 }
@@ -7151,13 +7290,16 @@ func (i *Interactive) doSessionImport(src string) {
71517290 i.invalidate()
71527291 return
71537292 }
7293+ i.markSessionTitleSwitching()
71547294 if err := i.cfg.LoadSession(newPath); err != nil {
7295+ i.restoreFailedSessionTitle()
71557296 i.mu.Lock()
71567297 i.statusErr = "import: load failed: " + err.Error()
71577298 i.mu.Unlock()
71587299 i.invalidate()
71597300 return
71607301 }
7302+ i.restoreLoadedSessionTitle()
71617303 i.mu.Lock()
71627304 i.statusOK = "imported and switched to session " + friendlyPath(newPath)
71637305 i.statusErr = ""
@@ -7347,7 +7489,9 @@ func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo in
73477489 i.setSessionTreeError("tree: " + err.Error())
73487490 return
73497491 }
7492+ i.markSessionTitleSwitching()
73507493 if err := i.cfg.LoadSession(newPath); err != nil {
7494+ i.restoreFailedSessionTitle()
73517495 i.setSessionTreeError("tree: checkout failed: " + err.Error())
73527496 return
73537497 }
@@ -7355,6 +7499,8 @@ func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo in
73557499 // LoadSession is intentionally the old func(string) error callback: the
73567500 // CLI and embedders already own agent/session swapping. Refresh the view
73577501 // from the swapped agent here instead of requiring a new callback result.
7502+ i.resetSessionTitleForFreshBranch()
7503+ i.scrollToBottom()
73587504 i.mu.Lock()
73597505 i.lastCtxInput = 0
73607506 if i.agent != nil {
@@ -7422,13 +7568,16 @@ func (i *Interactive) applyForkSelection(msgIdx int) {
74227568 i.invalidate()
74237569 return
74247570 }
7571+ i.markSessionTitleSwitching()
74257572 if err := i.cfg.LoadSession(newPath); err != nil {
7573+ i.restoreFailedSessionTitle()
74267574 i.mu.Lock()
74277575 i.statusErr = "fork: switch failed: " + err.Error()
74287576 i.mu.Unlock()
74297577 i.invalidate()
74307578 return
74317579 }
7580+ i.resetSessionTitleForFreshBranch()
74327581 i.mu.Lock()
74337582 i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath)
74347583 i.statusErr = ""
@@ -63,6 +63,10 @@ type InteractiveConfig struct {
63 // agent and extensions. nil means enabled; false disables them.63 // agent and extensions. nil means enabled; false disables them.
64 TerminalAlertsEnabled *bool64 TerminalAlertsEnabled *bool
65 65
66+ // TerminalTitleEnabled controls the hidden title request and terminal
67+ // title updates. nil means enabled; false disables both.
68+ TerminalTitleEnabled *bool
69+
66 // AutoSwarmEnabled mirrors the persisted config flag at startup so70 // AutoSwarmEnabled mirrors the persisted config flag at startup so
67 // the /settings dialog can render the current state without71 // the /settings dialog can render the current state without
68 // re-reading config.json on every open.72 // re-reading config.json on every open.
@@ -267,6 +271,29 @@ type InteractiveConfig struct {
267 // write a new meta row so resume picks up the same model.271 // write a new meta row so resume picks up the same model.
268 PersistModel func(providerName, model string)272 PersistModel func(providerName, model string)
269 273
274+ // InitialSessionTitle is the persisted title of the session loaded before
275+ // the TUI starts. It is shown in the terminal without another model call.
276+ InitialSessionTitle string
277+ // InitialSessionTitlePending is true for a newly forked branch that only
278+ // contains its copied prefix. The copied user messages must not prevent a
279+ // new title after the branch's first real prompt.
280+ InitialSessionTitlePending bool
281+
282+ // CurrentSessionTitle returns the title of the session currently owned by
283+ // the host. It is used after /sessions and import switches.
284+ CurrentSessionTitle func() string
285+ // CurrentSessionTitlePending reports whether the loaded session is a new
286+ // branch that has not received a post-fork prompt yet.
287+ CurrentSessionTitlePending func() bool
288+
289+ // PersistTitle stores an automatically generated title without adding a
290+ // user or assistant message to the transcript. It returns persistence
291+ // errors without affecting the main turn.
292+ PersistTitle func(title string) error
293+ // OnSessionTitleChanged updates host-side in-memory title bookkeeping
294+ // after a manual rename has already been written to disk.
295+ OnSessionTitleChanged func(title string)
296+
270 OnAssistant func(m provider.Message)297 OnAssistant func(m provider.Message)
271 OnToolResult func(id string, r core.ToolResult)298 OnToolResult func(id string, r core.ToolResult)
272 299
@@ -373,6 +400,10 @@ type terminalAlertsSettingsStore interface {
373 SetTerminalAlertsEnabled(enabled bool) error400 SetTerminalAlertsEnabled(enabled bool) error
374 }401 }
375 402
403+type terminalTitleSettingsStore interface {
404+ SetTerminalTitleEnabled(enabled bool) error
405+}
406+
376 type showInstructionsSettingsStore interface {407 type showInstructionsSettingsStore interface {
377 SetShowInstructionsAtStartup(enabled bool) error408 SetShowInstructionsAtStartup(enabled bool) error
378 }409 }
@@ -569,6 +600,18 @@ type Interactive struct {
569 // after this point and reverts to plain text after.600 // after this point and reverts to plain text after.
570 welcomeStart time.Time601 welcomeStart time.Time
571 602
603+ // interactiveStarted gates session-title generation so tests and
604+ // embedders that call Submit before Run do not issue a hidden request.
605+ interactiveStarted bool
606+ // titleRealPromptSeen prevents a fresh session from generating more than
607+ // one title. It is reset only when a fork or a new cwd starts a new branch.
608+ titleRealPromptSeen bool
609+ titleGenerationStarted bool
610+ firstRealPrompt string
611+ titleVersion uint64
612+ titleCancel context.CancelFunc
613+ sessionTitle string
614+
572 // extNotes are one-shot styled lines pushed by extensions via615 // extNotes are one-shot styled lines pushed by extensions via
573 // Notify / Display. They live above the editor (just below the616 // Notify / Display. They live above the editor (just below the
574 // transcript) until cleared by /clear or another reset.617 // transcript) until cleared by /clear or another reset.
@@ -721,6 +764,15 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
721 i.view.TailLimit = initialResumeTailLimit764 i.view.TailLimit = initialResumeTailLimit
722 }765 }
723 }766 }
767+ i.sessionTitle = core.NormalizeSessionTitle(cfg.InitialSessionTitle)
768+ if cfg.InitialSessionTitlePending {
769+ i.sessionTitle = ""
770+ i.titleRealPromptSeen = false
771+ i.titleGenerationStarted = false
772+ } else {
773+ i.titleRealPromptSeen = i.sessionTitle != "" || hasRealUserPrompt(i.view.Messages)
774+ i.titleGenerationStarted = i.titleRealPromptSeen
775+ }
724 if cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled {776 if cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled {
725 i.applyAutoSwarmTool(true)777 i.applyAutoSwarmTool(true)
726 }778 }
@@ -736,6 +788,8 @@ func (i *Interactive) Run(ctx context.Context) error {
736 return err788 return err
737 }789 }
738 defer restore()790 defer restore()
791+ i.markInteractiveStarted()
792+ defer i.cancelSessionTitle()
739 defer func() {793 defer func() {
740 if i.telegramBridge != nil {794 if i.telegramBridge != nil {
741 i.telegramBridge.Stop()795 i.telegramBridge.Stop()
@@ -2254,10 +2308,29 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2254 i.sessionDialog.Close()2308 i.sessionDialog.Close()
2255 return false2309 return false
2256 }2310 }
2311+ manualRenameCurrent := false
2312+ if k.Kind == tui.KeyEnter && i.sessionDialog.renaming && core.NormalizeSessionTitle(i.sessionDialog.rename) != "" && i.sessionDialog.cursor >= 0 && i.sessionDialog.cursor < len(i.sessionDialog.sessions) && i.cfg.CurrentSessionPath != nil {
2313+ path := i.sessionDialog.sessions[i.sessionDialog.cursor].Path
2314+ manualRenameCurrent = i.cfg.CurrentSessionPath() == path
2315+ if manualRenameCurrent {
2316+ i.markSessionTitleSwitching()
2317+ }
2318+ }
2257 act := i.sessionDialog.HandleKey(k)2319 act := i.sessionDialog.HandleKey(k)
2258 if act.Select {2320 if act.Select {
2259 i.applySessionSelection(act.Path)2321 i.applySessionSelection(act.Path)
2260 }2322 }
2323+ if act.Err != nil {
2324+ if manualRenameCurrent {
2325+ i.restoreFailedSessionTitle()
2326+ }
2327+ i.mu.Lock()
2328+ i.statusErr = "rename: " + act.Err.Error()
2329+ i.statusOK = ""
2330+ i.mu.Unlock()
2331+ } else if act.Renamed && act.Path != "" && i.cfg.CurrentSessionPath != nil && i.cfg.CurrentSessionPath() == act.Path {
2332+ i.setManualSessionTitle(act.RenameTitle)
2333+ }
2261 // Always request a redraw after handling a key here: when esc2334 // Always request a redraw after handling a key here: when esc
2262 // closes the picker, the overlay-close detection in the render2335 // closes the picker, the overlay-close detection in the render
2263 // pass needs to run so the tall dialog rows get repainted from2336 // pass needs to run so the tall dialog rows get repainted from
@@ -2836,6 +2909,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2836 if i.telegramBridge != nil && i.telegramBridge.Active() {2909 if i.telegramBridge != nil && i.telegramBridge.Active() {
2837 go i.telegramBridge.OnUserTyped(text)2910 go i.telegramBridge.OnUserTyped(text)
2838 }2911 }
2912+ i.maybeStartSessionTitle(ctx, text)
2839 // If a turn is already in flight, queue this prompt inside the2913 // If a turn is already in flight, queue this prompt inside the
2840 // agent loop so it is delivered at the next safe model-call2914 // agent loop so it is delivered at the next safe model-call
2841 // boundary instead of waiting for the whole run to finish.2915 // boundary instead of waiting for the whole run to finish.
@@ -3145,7 +3219,17 @@ func (i *Interactive) Submit(text string) {
3145 i.startShellEscape(i.runCtx, cmd)3219 i.startShellEscape(i.runCtx, cmd)
3146 return3220 return
3147 }3221 }
3148- i.startTurn(i.runCtx, text)3222+ parent := i.runCtx
3223+ if parent == nil {
3224+ parent = context.Background()
3225+ }
3226+ i.mu.Lock()
3227+ awaitingStartupPre := i.awaitingStartupPre
3228+ i.mu.Unlock()
3229+ if !awaitingStartupPre {
3230+ i.maybeStartSessionTitle(parent, text)
3231+ }
3232+ i.startTurn(parent, text)
3149 }3233 }
3150 3234
3151 // completeStartupPre applies deferred InitialInput after entry.pre finishes.3235 // completeStartupPre applies deferred InitialInput after entry.pre finishes.
@@ -3259,6 +3343,13 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin
3259 }3343 }
3260 i.cfg.Provider = provider3344 i.cfg.Provider = provider
3261 i.cfg.Model = model3345 i.cfg.Model = model
3346+ titleCancel := i.titleCancel
3347+ i.titleCancel = nil
3348+ i.titleVersion++
3349+ i.sessionTitle = ""
3350+ i.titleRealPromptSeen = false
3351+ i.titleGenerationStarted = false
3352+ i.writeTerminalTitleLocked("")
3262 i.toolCalls = map[string]*tui.ToolCallView{}3353 i.toolCalls = map[string]*tui.ToolCallView{}
3263 i.toolOrder = nil3354 i.toolOrder = nil
3264 i.toolGate = map[string]int{}3355 i.toolGate = map[string]int{}
@@ -3266,6 +3357,9 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin
3266 i.parkedTurn = 03357 i.parkedTurn = 0
3267 i.statusErr = ""3358 i.statusErr = ""
3268 i.mu.Unlock()3359 i.mu.Unlock()
3360+ if titleCancel != nil {
3361+ titleCancel()
3362+ }
3269 i.fileSuggest.Reset()3363 i.fileSuggest.Reset()
3270 i.fileSuggest.SetCWD(cwd)3364 i.fileSuggest.SetCWD(cwd)
3271 i.invalidate()3365 i.invalidate()
@@ -3312,6 +3406,9 @@ func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
3312 i.invalidate()3406 i.invalidate()
3313 return3407 return
3314 }3408 }
3409+ i.mu.Unlock()
3410+ i.maybeStartSessionTitle(i.runCtx, text)
3411+ i.mu.Lock()
3315 if i.busy {3412 if i.busy {
3316 // Queue text only; images are dropped for queued prompts. Compaction3413 // Queue text only; images are dropped for queued prompts. Compaction
3317 // uses the host queue because it has no active agent loop to drain3414 // uses the host queue because it has no active agent loop to drain
@@ -3513,6 +3610,7 @@ func (i *Interactive) openSettingsDialog() {
3513 }3610 }
3514 3611
3515 terminalAlerts := terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled)3612 terminalAlerts := terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled)
3613+ terminalTitles := terminalTitleEnabled(i.cfg.TerminalTitleEnabled)
3516 autoSwarm := false3614 autoSwarm := false
3517 if i.cfg.AutoSwarmEnabled != nil {3615 if i.cfg.AutoSwarmEnabled != nil {
3518 autoSwarm = *i.cfg.AutoSwarmEnabled3616 autoSwarm = *i.cfg.AutoSwarmEnabled
@@ -3634,6 +3732,12 @@ func (i *Interactive) openSettingsDialog() {
3634 desc: "ring the terminal when the main session needs attention or an extension raises an alert",3732 desc: "ring the terminal when the main session needs attention or an extension raises an alert",
3635 value: terminalAlerts,3733 value: terminalAlerts,
3636 },3734 },
3735+ {
3736+ key: "terminal_title_enabled",
3737+ label: "AI terminal titles",
3738+ desc: "ask the active model for a concise title after the first prompt; uses one extra hidden request",
3739+ value: terminalTitles,
3740+ },
3637 {3741 {
3638 key: "auto_swarm_enabled",3742 key: "auto_swarm_enabled",
3639 label: "auto-swarm",3743 label: "auto-swarm",
@@ -4005,6 +4109,35 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
4005 i.statusOK = "terminal alerts " + onOff(value)4109 i.statusOK = "terminal alerts " + onOff(value)
4006 i.statusErr = ""4110 i.statusErr = ""
4007 i.mu.Unlock()4111 i.mu.Unlock()
4112+ case "terminal_title_enabled":
4113+ val := value
4114+ var cancel context.CancelFunc
4115+ i.mu.Lock()
4116+ i.cfg.TerminalTitleEnabled = &val
4117+ if value {
4118+ i.writeTerminalTitleLocked(i.sessionTitle)
4119+ } else {
4120+ cancel = i.titleCancel
4121+ i.titleCancel = nil
4122+ i.titleVersion++
4123+ i.writeTerminalTitleLocked("")
4124+ }
4125+ i.mu.Unlock()
4126+ if cancel != nil {
4127+ cancel()
4128+ }
4129+ if store, ok := i.cfg.SettingsStore.(terminalTitleSettingsStore); ok {
4130+ if err := store.SetTerminalTitleEnabled(value); err != nil {
4131+ i.mu.Lock()
4132+ i.statusErr = "settings: " + err.Error()
4133+ i.mu.Unlock()
4134+ return
4135+ }
4136+ }
4137+ i.mu.Lock()
4138+ i.statusOK = "AI terminal titles " + onOff(value)
4139+ i.statusErr = ""
4140+ i.mu.Unlock()
4008 case "auto_swarm_enabled":4141 case "auto_swarm_enabled":
4009 val := value4142 val := value
4010 i.cfg.AutoSwarmEnabled = &val4143 i.cfg.AutoSwarmEnabled = &val
@@ -5367,18 +5500,23 @@ func (i *Interactive) applySessionSelection(path string) {
5367 i.statusErr = ""5500 i.statusErr = ""
5368 i.mu.Unlock()5501 i.mu.Unlock()
5369 i.invalidate()5502 i.invalidate()
5503+ i.markSessionTitleSwitching()
5370 5504
5371 go func() {5505 go func() {
5372 err := i.cfg.LoadSession(path)5506 err := i.cfg.LoadSession(path)
5373- i.mu.Lock()
5374- defer i.mu.Unlock()
5375- i.sessionLoading = false
5376 if err != nil {5507 if err != nil {
5508+ i.restoreFailedSessionTitle()
5509+ i.mu.Lock()
5510+ i.sessionLoading = false
5377 i.statusErr = err.Error()5511 i.statusErr = err.Error()
5378 i.statusOK = ""5512 i.statusOK = ""
5513+ i.mu.Unlock()
5379 i.invalidate()5514 i.invalidate()
5380 return5515 return
5381 }5516 }
5517+ i.restoreLoadedSessionTitle()
5518+ i.mu.Lock()
5519+ i.sessionLoading = false
5382 i.statusOK = "resumed session: " + path5520 i.statusOK = "resumed session: " + path
5383 i.statusErr = ""5521 i.statusErr = ""
5384 i.parkedTurn = 05522 i.parkedTurn = 0
@@ -5408,6 +5546,7 @@ func (i *Interactive) applySessionSelection(path string) {
5408 i.view.TailLimit = 05546 i.view.TailLimit = 0
5409 }5547 }
5410 }5548 }
5549+ i.mu.Unlock()
5411 i.invalidate()5550 i.invalidate()
5412 }()5551 }()
5413 }5552 }
@@ -7151,13 +7290,16 @@ func (i *Interactive) doSessionImport(src string) {
7151 i.invalidate()7290 i.invalidate()
7152 return7291 return
7153 }7292 }
7293+ i.markSessionTitleSwitching()
7154 if err := i.cfg.LoadSession(newPath); err != nil {7294 if err := i.cfg.LoadSession(newPath); err != nil {
7295+ i.restoreFailedSessionTitle()
7155 i.mu.Lock()7296 i.mu.Lock()
7156 i.statusErr = "import: load failed: " + err.Error()7297 i.statusErr = "import: load failed: " + err.Error()
7157 i.mu.Unlock()7298 i.mu.Unlock()
7158 i.invalidate()7299 i.invalidate()
7159 return7300 return
7160 }7301 }
7302+ i.restoreLoadedSessionTitle()
7161 i.mu.Lock()7303 i.mu.Lock()
7162 i.statusOK = "imported and switched to session " + friendlyPath(newPath)7304 i.statusOK = "imported and switched to session " + friendlyPath(newPath)
7163 i.statusErr = ""7305 i.statusErr = ""
@@ -7347,7 +7489,9 @@ func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo in
7347 i.setSessionTreeError("tree: " + err.Error())7489 i.setSessionTreeError("tree: " + err.Error())
7348 return7490 return
7349 }7491 }
7492+ i.markSessionTitleSwitching()
7350 if err := i.cfg.LoadSession(newPath); err != nil {7493 if err := i.cfg.LoadSession(newPath); err != nil {
7494+ i.restoreFailedSessionTitle()
7351 i.setSessionTreeError("tree: checkout failed: " + err.Error())7495 i.setSessionTreeError("tree: checkout failed: " + err.Error())
7352 return7496 return
7353 }7497 }
@@ -7355,6 +7499,8 @@ func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo in
7355 // LoadSession is intentionally the old func(string) error callback: the7499 // LoadSession is intentionally the old func(string) error callback: the
7356 // CLI and embedders already own agent/session swapping. Refresh the view7500 // CLI and embedders already own agent/session swapping. Refresh the view
7357 // from the swapped agent here instead of requiring a new callback result.7501 // from the swapped agent here instead of requiring a new callback result.
7502+ i.resetSessionTitleForFreshBranch()
7503+ i.scrollToBottom()
7358 i.mu.Lock()7504 i.mu.Lock()
7359 i.lastCtxInput = 07505 i.lastCtxInput = 0
7360 if i.agent != nil {7506 if i.agent != nil {
@@ -7422,13 +7568,16 @@ func (i *Interactive) applyForkSelection(msgIdx int) {
7422 i.invalidate()7568 i.invalidate()
7423 return7569 return
7424 }7570 }
7571+ i.markSessionTitleSwitching()
7425 if err := i.cfg.LoadSession(newPath); err != nil {7572 if err := i.cfg.LoadSession(newPath); err != nil {
7573+ i.restoreFailedSessionTitle()
7426 i.mu.Lock()7574 i.mu.Lock()
7427 i.statusErr = "fork: switch failed: " + err.Error()7575 i.statusErr = "fork: switch failed: " + err.Error()
7428 i.mu.Unlock()7576 i.mu.Unlock()
7429 i.invalidate()7577 i.invalidate()
7430 return7578 return
7431 }7579 }
7580+ i.resetSessionTitleForFreshBranch()
7432 i.mu.Lock()7581 i.mu.Lock()
7433 i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath)7582 i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath)
7434 i.statusErr = ""7583 i.statusErr = ""
M packages/agent/modes/session_dialog.go +19 -10
@@ -34,10 +34,12 @@ type sessionDialog struct {
3434
3535 // sessionDialogAction is returned by HandleKey.
3636 type sessionDialogAction struct {
37- Select bool
38- Path string
39- Close bool
40- Renamed bool
37+ Select bool
38+ Path string
39+ Close bool
40+ Renamed bool
41+ RenameTitle string
42+ Err error
4143 }
4244
4345 func newSessionDialog() *sessionDialog { return &sessionDialog{} }
@@ -239,15 +241,22 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
239241 if d.renaming {
240242 switch k.Kind {
241243 case tui.KeyEnter:
242- title := strings.TrimSpace(d.rename)
243- if title != "" && d.cursor < len(d.sessions) {
244- path := d.sessions[d.cursor].Path
245- _ = core.RenameSession(path, title)
246- d.sessions[d.cursor].Title = title
244+ title := core.NormalizeSessionTitle(d.rename)
245+ path := ""
246+ renamed := false
247+ var renameErr error
248+ if title != "" && d.cursor >= 0 && d.cursor < len(d.sessions) {
249+ path = d.sessions[d.cursor].Path
250+ if err := core.RenameSession(path, title); err != nil {
251+ renameErr = err
252+ } else {
253+ d.sessions[d.cursor].Title = title
254+ renamed = true
255+ }
247256 }
248257 d.renaming = false
249258 d.rename = ""
250- return sessionDialogAction{Renamed: true}
259+ return sessionDialogAction{Renamed: renamed, Path: path, RenameTitle: title, Err: renameErr}
251260 case tui.KeyEsc:
252261 d.renaming = false
253262 d.rename = ""
@@ -34,10 +34,12 @@ type sessionDialog struct {
34 34
35 // sessionDialogAction is returned by HandleKey.35 // sessionDialogAction is returned by HandleKey.
36 type sessionDialogAction struct {36 type sessionDialogAction struct {
37- Select bool37+ Select bool
38- Path string38+ Path string
39- Close bool39+ Close bool
40- Renamed bool40+ Renamed bool
41+ RenameTitle string
42+ Err error
41 }43 }
42 44
43 func newSessionDialog() *sessionDialog { return &sessionDialog{} }45 func newSessionDialog() *sessionDialog { return &sessionDialog{} }
@@ -239,15 +241,22 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
239 if d.renaming {241 if d.renaming {
240 switch k.Kind {242 switch k.Kind {
241 case tui.KeyEnter:243 case tui.KeyEnter:
242- title := strings.TrimSpace(d.rename)244+ title := core.NormalizeSessionTitle(d.rename)
243- if title != "" && d.cursor < len(d.sessions) {245+ path := ""
244- path := d.sessions[d.cursor].Path246+ renamed := false
245- _ = core.RenameSession(path, title)247+ var renameErr error
246- d.sessions[d.cursor].Title = title248+ if title != "" && d.cursor >= 0 && d.cursor < len(d.sessions) {
249+ path = d.sessions[d.cursor].Path
250+ if err := core.RenameSession(path, title); err != nil {
251+ renameErr = err
252+ } else {
253+ d.sessions[d.cursor].Title = title
254+ renamed = true
255+ }
247 }256 }
248 d.renaming = false257 d.renaming = false
249 d.rename = ""258 d.rename = ""
250- return sessionDialogAction{Renamed: true}259+ return sessionDialogAction{Renamed: renamed, Path: path, RenameTitle: title, Err: renameErr}
251 case tui.KeyEsc:260 case tui.KeyEsc:
252 d.renaming = false261 d.renaming = false
253 d.rename = ""262 d.rename = ""
A packages/agent/modes/session_title.go +266 -0
@@ -0,0 +1,266 @@
1+package modes
2+
3+import (
4+ "context"
5+ "strings"
6+ "time"
7+
8+ "github.com/patriceckhart/zot/packages/core"
9+ "github.com/patriceckhart/zot/packages/provider"
10+ "github.com/patriceckhart/zot/packages/tui"
11+)
12+
13+const sessionTitleGenerationTimeout = 10 * time.Second
14+
15+func terminalTitleEnabled(enabled *bool) bool {
16+ return enabled == nil || *enabled
17+}
18+
19+// hasRealUserPrompt ignores transcript rows that are context for the model but
20+// not a prompt that started work: shell escapes and image mirrors are both
21+// user-role messages internally.
22+func hasRealUserPrompt(messages []provider.Message) bool {
23+ for _, message := range messages {
24+ if message.Role != provider.RoleUser || isHiddenTranscriptMessage(message) || message.Meta[shellEscapeMetaKey] == "true" {
25+ continue
26+ }
27+ if strings.TrimSpace(userMessageText(message)) != "" || len(message.Content) > 0 {
28+ return true
29+ }
30+ }
31+ return false
32+}
33+
34+// maybeStartSessionTitle marks the first real prompt and starts the hidden
35+// title request. It is safe to call at every prompt boundary; the state guard
36+// makes it a one-shot per interactive session/branch.
37+func (i *Interactive) maybeStartSessionTitle(parent context.Context, prompt string) {
38+ if parent == nil {
39+ parent = i.runCtx
40+ }
41+ if parent == nil {
42+ parent = context.Background()
43+ }
44+
45+ i.mu.Lock()
46+ if !i.interactiveStarted || i.agent == nil || i.titleRealPromptSeen || i.titleGenerationStarted {
47+ i.mu.Unlock()
48+ return
49+ }
50+ i.titleRealPromptSeen = true
51+ i.firstRealPrompt = prompt
52+ if !terminalTitleEnabled(i.cfg.TerminalTitleEnabled) {
53+ i.mu.Unlock()
54+ return
55+ }
56+ i.titleGenerationStarted = true
57+ agent := i.agent
58+ client := agent.Client
59+ model := agent.Model
60+ version := i.titleVersion
61+ i.mu.Unlock()
62+
63+ if strings.TrimSpace(prompt) == "" {
64+ i.finishSessionTitle(version, "new session")
65+ return
66+ }
67+
68+ ctx, cancel := context.WithTimeout(parent, sessionTitleGenerationTimeout)
69+ i.mu.Lock()
70+ if version != i.titleVersion {
71+ i.mu.Unlock()
72+ cancel()
73+ return
74+ }
75+ i.titleCancel = cancel
76+ i.mu.Unlock()
77+
78+ go func() {
79+ defer cancel()
80+ title, err := core.GenerateSessionTitle(ctx, client, model, prompt)
81+ if err != nil {
82+ // Title generation is deliberately best effort. The main turn
83+ // must not depend on this second request succeeding.
84+ title = core.NormalizeSessionTitle(prompt)
85+ }
86+ if parent.Err() != nil {
87+ return
88+ }
89+ if title == "" {
90+ title = "new session"
91+ }
92+ i.finishSessionTitle(version, title)
93+ }()
94+}
95+
96+// finishSessionTitle applies a generated or fallback title only if the
97+// session has not been switched, forked, or manually renamed meanwhile.
98+func (i *Interactive) finishSessionTitle(version uint64, title string) {
99+ title = core.NormalizeSessionTitle(title)
100+ if title == "" {
101+ title = "new session"
102+ }
103+
104+ i.mu.Lock()
105+ if version != i.titleVersion || !terminalTitleEnabled(i.cfg.TerminalTitleEnabled) {
106+ i.mu.Unlock()
107+ return
108+ }
109+ i.titleCancel = nil
110+ i.sessionTitle = title
111+ if i.cfg.PersistTitle != nil {
112+ if err := i.cfg.PersistTitle(title); err != nil {
113+ i.statusErr = "session title: " + err.Error()
114+ }
115+ }
116+ i.writeTerminalTitleLocked(title)
117+ i.mu.Unlock()
118+ i.invalidate()
119+}
120+
121+func (i *Interactive) writeTerminalTitleLocked(subject string) {
122+ if i.cfg.Terminal == nil {
123+ return
124+ }
125+ subject = core.NormalizeSessionTitle(subject)
126+ if subject != "" && !terminalTitleEnabled(i.cfg.TerminalTitleEnabled) {
127+ return
128+ }
129+ display := "zot"
130+ if subject != "" {
131+ display += ": " + subject
132+ }
133+ _, _ = i.cfg.Terminal.Write([]byte(tui.SetTitle(display)))
134+}
135+
136+func (i *Interactive) markSessionTitleSwitching() {
137+ i.mu.Lock()
138+ cancel := i.titleCancel
139+ i.titleCancel = nil
140+ i.titleVersion++
141+ i.titleGenerationStarted = true
142+ i.mu.Unlock()
143+ if cancel != nil {
144+ cancel()
145+ }
146+}
147+
148+// CancelPendingSessionTitle invalidates a title request before a host swaps
149+// the session it owns. Hosts must call it before changing their session
150+// pointer so a late result cannot be persisted into the replacement session.
151+func (i *Interactive) CancelPendingSessionTitle() {
152+ i.markSessionTitleSwitching()
153+}
154+
155+func (i *Interactive) cancelSessionTitle() {
156+ i.mu.Lock()
157+ cancel := i.titleCancel
158+ i.titleCancel = nil
159+ i.titleVersion++
160+ i.mu.Unlock()
161+ if cancel != nil {
162+ cancel()
163+ }
164+}
165+
166+func (i *Interactive) restoreLoadedSessionTitle() {
167+ pending := false
168+ if i.cfg.CurrentSessionTitlePending != nil {
169+ pending = i.cfg.CurrentSessionTitlePending()
170+ }
171+ title := ""
172+ if !pending && i.cfg.CurrentSessionTitle != nil {
173+ title = i.cfg.CurrentSessionTitle()
174+ }
175+
176+ i.mu.Lock()
177+ i.titleVersion++
178+ i.sessionTitle = core.NormalizeSessionTitle(title)
179+ i.firstRealPrompt = ""
180+ if pending {
181+ i.titleRealPromptSeen = false
182+ i.titleGenerationStarted = false
183+ } else {
184+ i.titleRealPromptSeen = hasRealUserPrompt(i.agentMessagesLocked())
185+ i.titleGenerationStarted = i.titleRealPromptSeen || i.sessionTitle != ""
186+ }
187+ i.writeTerminalTitleLocked(i.sessionTitle)
188+ i.mu.Unlock()
189+}
190+
191+func (i *Interactive) restoreFailedSessionTitle() {
192+ i.mu.Lock()
193+ prompt := i.firstRealPrompt
194+ i.mu.Unlock()
195+ if prompt == "" {
196+ i.restoreLoadedSessionTitle()
197+ return
198+ }
199+ i.restoreLoadedSessionTitle()
200+ i.mu.Lock()
201+ if i.sessionTitle != "" {
202+ i.mu.Unlock()
203+ return
204+ }
205+ i.firstRealPrompt = prompt
206+ i.titleRealPromptSeen = false
207+ i.titleGenerationStarted = false
208+ i.mu.Unlock()
209+ i.maybeStartSessionTitle(i.runCtx, prompt)
210+}
211+
212+func (i *Interactive) resetSessionTitleForFreshBranch() {
213+ i.mu.Lock()
214+ cancel := i.titleCancel
215+ i.titleCancel = nil
216+ i.titleVersion++
217+ i.sessionTitle = ""
218+ i.titleRealPromptSeen = false
219+ i.titleGenerationStarted = false
220+ i.firstRealPrompt = ""
221+ i.writeTerminalTitleLocked("")
222+ i.mu.Unlock()
223+ if cancel != nil {
224+ cancel()
225+ }
226+}
227+
228+func (i *Interactive) agentMessagesLocked() []provider.Message {
229+ if i.agent == nil {
230+ return nil
231+ }
232+ return i.agent.Messages()
233+}
234+
235+func (i *Interactive) setManualSessionTitle(title string) {
236+ title = core.NormalizeSessionTitle(title)
237+ if title == "" {
238+ return
239+ }
240+ i.mu.Lock()
241+ cancel := i.titleCancel
242+ i.titleCancel = nil
243+ i.titleVersion++
244+ i.sessionTitle = title
245+ i.titleRealPromptSeen = true
246+ i.titleGenerationStarted = true
247+ i.firstRealPrompt = ""
248+ i.writeTerminalTitleLocked(title)
249+ onChanged := i.cfg.OnSessionTitleChanged
250+ i.mu.Unlock()
251+ if cancel != nil {
252+ cancel()
253+ }
254+ if onChanged != nil {
255+ onChanged(title)
256+ }
257+}
258+
259+func (i *Interactive) markInteractiveStarted() {
260+ i.mu.Lock()
261+ i.interactiveStarted = true
262+ if i.sessionTitle != "" {
263+ i.writeTerminalTitleLocked(i.sessionTitle)
264+ }
265+ i.mu.Unlock()
266+}
@@ -0,0 +1,266 @@
1+package modes
2+
3+import (
4+ "context"
5+ "strings"
6+ "time"
7+
8+ "github.com/patriceckhart/zot/packages/core"
9+ "github.com/patriceckhart/zot/packages/provider"
10+ "github.com/patriceckhart/zot/packages/tui"
11+)
12+
13+const sessionTitleGenerationTimeout = 10 * time.Second
14+
15+func terminalTitleEnabled(enabled *bool) bool {
16+ return enabled == nil || *enabled
17+}
18+
19+// hasRealUserPrompt ignores transcript rows that are context for the model but
20+// not a prompt that started work: shell escapes and image mirrors are both
21+// user-role messages internally.
22+func hasRealUserPrompt(messages []provider.Message) bool {
23+ for _, message := range messages {
24+ if message.Role != provider.RoleUser || isHiddenTranscriptMessage(message) || message.Meta[shellEscapeMetaKey] == "true" {
25+ continue
26+ }
27+ if strings.TrimSpace(userMessageText(message)) != "" || len(message.Content) > 0 {
28+ return true
29+ }
30+ }
31+ return false
32+}
33+
34+// maybeStartSessionTitle marks the first real prompt and starts the hidden
35+// title request. It is safe to call at every prompt boundary; the state guard
36+// makes it a one-shot per interactive session/branch.
37+func (i *Interactive) maybeStartSessionTitle(parent context.Context, prompt string) {
38+ if parent == nil {
39+ parent = i.runCtx
40+ }
41+ if parent == nil {
42+ parent = context.Background()
43+ }
44+
45+ i.mu.Lock()
46+ if !i.interactiveStarted || i.agent == nil || i.titleRealPromptSeen || i.titleGenerationStarted {
47+ i.mu.Unlock()
48+ return
49+ }
50+ i.titleRealPromptSeen = true
51+ i.firstRealPrompt = prompt
52+ if !terminalTitleEnabled(i.cfg.TerminalTitleEnabled) {
53+ i.mu.Unlock()
54+ return
55+ }
56+ i.titleGenerationStarted = true
57+ agent := i.agent
58+ client := agent.Client
59+ model := agent.Model
60+ version := i.titleVersion
61+ i.mu.Unlock()
62+
63+ if strings.TrimSpace(prompt) == "" {
64+ i.finishSessionTitle(version, "new session")
65+ return
66+ }
67+
68+ ctx, cancel := context.WithTimeout(parent, sessionTitleGenerationTimeout)
69+ i.mu.Lock()
70+ if version != i.titleVersion {
71+ i.mu.Unlock()
72+ cancel()
73+ return
74+ }
75+ i.titleCancel = cancel
76+ i.mu.Unlock()
77+
78+ go func() {
79+ defer cancel()
80+ title, err := core.GenerateSessionTitle(ctx, client, model, prompt)
81+ if err != nil {
82+ // Title generation is deliberately best effort. The main turn
83+ // must not depend on this second request succeeding.
84+ title = core.NormalizeSessionTitle(prompt)
85+ }
86+ if parent.Err() != nil {
87+ return
88+ }
89+ if title == "" {
90+ title = "new session"
91+ }
92+ i.finishSessionTitle(version, title)
93+ }()
94+}
95+
96+// finishSessionTitle applies a generated or fallback title only if the
97+// session has not been switched, forked, or manually renamed meanwhile.
98+func (i *Interactive) finishSessionTitle(version uint64, title string) {
99+ title = core.NormalizeSessionTitle(title)
100+ if title == "" {
101+ title = "new session"
102+ }
103+
104+ i.mu.Lock()
105+ if version != i.titleVersion || !terminalTitleEnabled(i.cfg.TerminalTitleEnabled) {
106+ i.mu.Unlock()
107+ return
108+ }
109+ i.titleCancel = nil
110+ i.sessionTitle = title
111+ if i.cfg.PersistTitle != nil {
112+ if err := i.cfg.PersistTitle(title); err != nil {
113+ i.statusErr = "session title: " + err.Error()
114+ }
115+ }
116+ i.writeTerminalTitleLocked(title)
117+ i.mu.Unlock()
118+ i.invalidate()
119+}
120+
121+func (i *Interactive) writeTerminalTitleLocked(subject string) {
122+ if i.cfg.Terminal == nil {
123+ return
124+ }
125+ subject = core.NormalizeSessionTitle(subject)
126+ if subject != "" && !terminalTitleEnabled(i.cfg.TerminalTitleEnabled) {
127+ return
128+ }
129+ display := "zot"
130+ if subject != "" {
131+ display += ": " + subject
132+ }
133+ _, _ = i.cfg.Terminal.Write([]byte(tui.SetTitle(display)))
134+}
135+
136+func (i *Interactive) markSessionTitleSwitching() {
137+ i.mu.Lock()
138+ cancel := i.titleCancel
139+ i.titleCancel = nil
140+ i.titleVersion++
141+ i.titleGenerationStarted = true
142+ i.mu.Unlock()
143+ if cancel != nil {
144+ cancel()
145+ }
146+}
147+
148+// CancelPendingSessionTitle invalidates a title request before a host swaps
149+// the session it owns. Hosts must call it before changing their session
150+// pointer so a late result cannot be persisted into the replacement session.
151+func (i *Interactive) CancelPendingSessionTitle() {
152+ i.markSessionTitleSwitching()
153+}
154+
155+func (i *Interactive) cancelSessionTitle() {
156+ i.mu.Lock()
157+ cancel := i.titleCancel
158+ i.titleCancel = nil
159+ i.titleVersion++
160+ i.mu.Unlock()
161+ if cancel != nil {
162+ cancel()
163+ }
164+}
165+
166+func (i *Interactive) restoreLoadedSessionTitle() {
167+ pending := false
168+ if i.cfg.CurrentSessionTitlePending != nil {
169+ pending = i.cfg.CurrentSessionTitlePending()
170+ }
171+ title := ""
172+ if !pending && i.cfg.CurrentSessionTitle != nil {
173+ title = i.cfg.CurrentSessionTitle()
174+ }
175+
176+ i.mu.Lock()
177+ i.titleVersion++
178+ i.sessionTitle = core.NormalizeSessionTitle(title)
179+ i.firstRealPrompt = ""
180+ if pending {
181+ i.titleRealPromptSeen = false
182+ i.titleGenerationStarted = false
183+ } else {
184+ i.titleRealPromptSeen = hasRealUserPrompt(i.agentMessagesLocked())
185+ i.titleGenerationStarted = i.titleRealPromptSeen || i.sessionTitle != ""
186+ }
187+ i.writeTerminalTitleLocked(i.sessionTitle)
188+ i.mu.Unlock()
189+}
190+
191+func (i *Interactive) restoreFailedSessionTitle() {
192+ i.mu.Lock()
193+ prompt := i.firstRealPrompt
194+ i.mu.Unlock()
195+ if prompt == "" {
196+ i.restoreLoadedSessionTitle()
197+ return
198+ }
199+ i.restoreLoadedSessionTitle()
200+ i.mu.Lock()
201+ if i.sessionTitle != "" {
202+ i.mu.Unlock()
203+ return
204+ }
205+ i.firstRealPrompt = prompt
206+ i.titleRealPromptSeen = false
207+ i.titleGenerationStarted = false
208+ i.mu.Unlock()
209+ i.maybeStartSessionTitle(i.runCtx, prompt)
210+}
211+
212+func (i *Interactive) resetSessionTitleForFreshBranch() {
213+ i.mu.Lock()
214+ cancel := i.titleCancel
215+ i.titleCancel = nil
216+ i.titleVersion++
217+ i.sessionTitle = ""
218+ i.titleRealPromptSeen = false
219+ i.titleGenerationStarted = false
220+ i.firstRealPrompt = ""
221+ i.writeTerminalTitleLocked("")
222+ i.mu.Unlock()
223+ if cancel != nil {
224+ cancel()
225+ }
226+}
227+
228+func (i *Interactive) agentMessagesLocked() []provider.Message {
229+ if i.agent == nil {
230+ return nil
231+ }
232+ return i.agent.Messages()
233+}
234+
235+func (i *Interactive) setManualSessionTitle(title string) {
236+ title = core.NormalizeSessionTitle(title)
237+ if title == "" {
238+ return
239+ }
240+ i.mu.Lock()
241+ cancel := i.titleCancel
242+ i.titleCancel = nil
243+ i.titleVersion++
244+ i.sessionTitle = title
245+ i.titleRealPromptSeen = true
246+ i.titleGenerationStarted = true
247+ i.firstRealPrompt = ""
248+ i.writeTerminalTitleLocked(title)
249+ onChanged := i.cfg.OnSessionTitleChanged
250+ i.mu.Unlock()
251+ if cancel != nil {
252+ cancel()
253+ }
254+ if onChanged != nil {
255+ onChanged(title)
256+ }
257+}
258+
259+func (i *Interactive) markInteractiveStarted() {
260+ i.mu.Lock()
261+ i.interactiveStarted = true
262+ if i.sessionTitle != "" {
263+ i.writeTerminalTitleLocked(i.sessionTitle)
264+ }
265+ i.mu.Unlock()
266+}
A packages/agent/modes/session_title_test.go +296 -0
@@ -0,0 +1,296 @@
1+package modes
2+
3+import (
4+ "context"
5+ "errors"
6+ "strings"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/patriceckhart/zot/packages/core"
12+ "github.com/patriceckhart/zot/packages/provider"
13+)
14+
15+type interactiveTitleClient struct {
16+ mu sync.Mutex
17+ requests []provider.Request
18+ title string
19+ titleErr error
20+}
21+
22+func (c *interactiveTitleClient) Name() string { return "interactive-title-test" }
23+
24+func (c *interactiveTitleClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
25+ c.mu.Lock()
26+ c.requests = append(c.requests, req)
27+ c.mu.Unlock()
28+ if req.System != "" && c.titleErr != nil {
29+ return nil, c.titleErr
30+ }
31+ out := make(chan provider.Event, 2)
32+ if req.System != "" {
33+ out <- provider.EventTextDelta{Delta: c.title}
34+ out <- provider.EventDone{Stop: provider.StopEnd}
35+ } else {
36+ out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
37+ Role: provider.RoleAssistant,
38+ Content: []provider.Content{provider.TextBlock{Text: "main response"}},
39+ }}
40+ }
41+ close(out)
42+ return out, nil
43+}
44+
45+func (c *interactiveTitleClient) requestCount() int {
46+ c.mu.Lock()
47+ defer c.mu.Unlock()
48+ return len(c.requests)
49+}
50+
51+func waitInteractiveIdle(t *testing.T, i *Interactive) {
52+ t.Helper()
53+ deadline := time.Now().Add(time.Second)
54+ for time.Now().Before(deadline) {
55+ i.mu.Lock()
56+ busy := i.busy
57+ i.mu.Unlock()
58+ if !busy {
59+ return
60+ }
61+ time.Sleep(time.Millisecond)
62+ }
63+ t.Fatal("interactive turn did not finish")
64+}
65+
66+func waitInteractiveTitle(t *testing.T, i *Interactive, want string) {
67+ t.Helper()
68+ deadline := time.Now().Add(time.Second)
69+ for time.Now().Before(deadline) {
70+ i.mu.Lock()
71+ got := i.sessionTitle
72+ i.mu.Unlock()
73+ if got == want {
74+ return
75+ }
76+ time.Sleep(time.Millisecond)
77+ }
78+ i.mu.Lock()
79+ got := i.sessionTitle
80+ i.mu.Unlock()
81+ t.Fatalf("session title = %q, want %q", got, want)
82+}
83+
84+func waitTerminalOutput(t *testing.T, term *alertTestTerminal, want string) {
85+ t.Helper()
86+ deadline := time.Now().Add(time.Second)
87+ for time.Now().Before(deadline) {
88+ if got := term.String(); got == want {
89+ return
90+ }
91+ time.Sleep(time.Millisecond)
92+ }
93+ t.Fatalf("terminal title output = %q, want %q", term.String(), want)
94+}
95+
96+func TestFirstRealPromptSetsAndPersistsTerminalTitle(t *testing.T) {
97+ term := &alertTestTerminal{}
98+ client := &interactiveTitleClient{title: "Fix login retries"}
99+ ag := core.NewAgent(client, "test-model", "", nil)
100+ persisted := make(chan string, 1)
101+ i := NewInteractive(InteractiveConfig{
102+ Agent: ag,
103+ Terminal: term,
104+ PersistTitle: func(title string) error {
105+ persisted <- title
106+ return nil
107+ },
108+ })
109+ i.markInteractiveStarted()
110+
111+ i.Submit("Please fix login retries")
112+ waitInteractiveIdle(t, i)
113+
114+ select {
115+ case got := <-persisted:
116+ if got != "Fix login retries" {
117+ t.Fatalf("persisted title = %q, want %q", got, "Fix login retries")
118+ }
119+ case <-time.After(time.Second):
120+ t.Fatal("title was not persisted")
121+ }
122+ waitTerminalOutput(t, term, "\x1b]0;zot: Fix login retries\x07")
123+ if got := len(ag.Messages()); got != 2 {
124+ t.Fatalf("transcript message count = %d, want user + assistant only", got)
125+ }
126+ if got := client.requestCount(); got != 2 {
127+ t.Fatalf("provider request count = %d, want main + hidden title", got)
128+ }
129+}
130+
131+func TestTitleFailureFallsBackWithoutFailingMainTurn(t *testing.T) {
132+ term := &alertTestTerminal{}
133+ client := &interactiveTitleClient{titleErr: errors.New("title provider unavailable")}
134+ ag := core.NewAgent(client, "test-model", "", nil)
135+ persisted := make(chan string, 1)
136+ i := NewInteractive(InteractiveConfig{
137+ Agent: ag,
138+ Terminal: term,
139+ PersistTitle: func(title string) error {
140+ persisted <- title
141+ return nil
142+ },
143+ })
144+ i.markInteractiveStarted()
145+
146+ prompt := "Please fix the login retry behavior"
147+ i.Submit(prompt)
148+ waitInteractiveIdle(t, i)
149+
150+ select {
151+ case got := <-persisted:
152+ if got != "Please fix the login retry behavior" {
153+ t.Fatalf("fallback title = %q, want prompt text", got)
154+ }
155+ case <-time.After(time.Second):
156+ t.Fatal("fallback title was not persisted")
157+ }
158+ if got := len(ag.Messages()); got != 2 {
159+ t.Fatalf("transcript message count = %d, want main turn only", got)
160+ }
161+}
162+
163+func TestSkillSlashPromptDoesNotConsumeTheFirstRealPromptTitle(t *testing.T) {
164+ term := &alertTestTerminal{}
165+ client := &interactiveTitleClient{title: "should not be used"}
166+ ag := core.NewAgent(client, "test-model", "", nil)
167+ i := NewInteractive(InteractiveConfig{Agent: ag, Terminal: term})
168+ i.markInteractiveStarted()
169+
170+ i.submitOrQueuePrompt(context.Background(), "expanded skill instructions")
171+ waitInteractiveIdle(t, i)
172+
173+ if got := client.requestCount(); got != 1 {
174+ t.Fatalf("provider request count = %d, want only the slash-expanded main request", got)
175+ }
176+ i.mu.Lock()
177+ seen := i.titleRealPromptSeen
178+ i.mu.Unlock()
179+ if seen {
180+ t.Fatal("slash-expanded prompt consumed the first real prompt title slot")
181+ }
182+}
183+
184+func TestStartupPreDoesNotConsumeTheFirstRealPromptTitle(t *testing.T) {
185+ term := &alertTestTerminal{}
186+ client := &interactiveTitleClient{title: "real prompt title"}
187+ ag := core.NewAgent(client, "test-model", "", nil)
188+ i := NewInteractive(InteractiveConfig{Agent: ag, Terminal: term})
189+ i.markInteractiveStarted()
190+ i.awaitingStartupPre = true
191+
192+ i.Submit("load startup resources")
193+ waitInteractiveIdle(t, i)
194+
195+ if got := client.requestCount(); got != 1 {
196+ t.Fatalf("startup pre request count = %d, want only the startup request", got)
197+ }
198+ i.mu.Lock()
199+ seen := i.titleRealPromptSeen
200+ i.mu.Unlock()
201+ if seen {
202+ t.Fatal("startup pre consumed the first real prompt title slot")
203+ }
204+}
205+
206+func TestDisabledTerminalTitlesSkipHiddenRequest(t *testing.T) {
207+ term := &alertTestTerminal{}
208+ disabled := false
209+ client := &interactiveTitleClient{title: "should not be used"}
210+ ag := core.NewAgent(client, "test-model", "", nil)
211+ i := NewInteractive(InteractiveConfig{Agent: ag, Terminal: term, TerminalTitleEnabled: &disabled})
212+ i.markInteractiveStarted()
213+
214+ i.startTurn(context.Background(), "do the work")
215+ waitInteractiveIdle(t, i)
216+
217+ if got := client.requestCount(); got != 1 {
218+ t.Fatalf("provider request count = %d, want only the main request", got)
219+ }
220+ if got := term.String(); got != "" {
221+ t.Fatalf("disabled title output = %q, want empty", got)
222+ }
223+}
224+
225+func TestFreshBranchTitleIgnoresCopiedPrefix(t *testing.T) {
226+ term := &alertTestTerminal{}
227+ client := &interactiveTitleClient{title: "new branch title"}
228+ ag := core.NewAgent(client, "test-model", "", nil)
229+ ag.SetMessages([]provider.Message{{
230+ Role: provider.RoleUser,
231+ Content: []provider.Content{provider.TextBlock{Text: "copied parent prompt"}},
232+ }})
233+ i := NewInteractive(InteractiveConfig{
234+ Agent: ag,
235+ Terminal: term,
236+ InitialSessionTitlePending: true,
237+ })
238+ i.markInteractiveStarted()
239+
240+ i.Submit("new branch work")
241+ waitInteractiveIdle(t, i)
242+
243+ if got := client.requestCount(); got != 2 {
244+ t.Fatalf("provider request count = %d, want main + hidden title", got)
245+ }
246+ waitInteractiveTitle(t, i, "new branch title")
247+ i.mu.Lock()
248+ seen := i.titleRealPromptSeen
249+ i.mu.Unlock()
250+ if !seen {
251+ t.Fatal("fresh branch did not record its first real prompt")
252+ }
253+}
254+
255+func TestDisablingTerminalTitlesRestoresNeutralTitle(t *testing.T) {
256+ term := &alertTestTerminal{}
257+ enabled := true
258+ i := NewInteractive(InteractiveConfig{
259+ Terminal: term,
260+ TerminalTitleEnabled: &enabled,
261+ InitialSessionTitle: "Existing work",
262+ })
263+ i.markInteractiveStarted()
264+ i.applySettingToggle("terminal_title_enabled", false)
265+
266+ got := term.String()
267+ if !strings.Contains(got, "\x1b]0;zot: Existing work\x07") || !strings.Contains(got, "\x1b]0;zot\x07") {
268+ t.Fatalf("terminal title output = %q, want existing and neutral title sequences", got)
269+ }
270+}
271+
272+func TestLoadedSessionTitleIsRestoredWithoutGeneration(t *testing.T) {
273+ term := &alertTestTerminal{}
274+ ag := core.NewAgent(nil, "test-model", "", nil)
275+ ag.SetMessages([]provider.Message{{
276+ Role: provider.RoleUser,
277+ Content: []provider.Content{provider.TextBlock{Text: "existing prompt"}},
278+ }})
279+ i := NewInteractive(InteractiveConfig{
280+ Agent: ag,
281+ Terminal: term,
282+ InitialSessionTitle: "Existing work",
283+ })
284+ i.markInteractiveStarted()
285+
286+ if got, want := term.String(), "\x1b]0;zot: Existing work\x07"; got != want {
287+ t.Fatalf("restored terminal title = %q, want %q", got, want)
288+ }
289+ i.mu.Lock()
290+ seen := i.titleRealPromptSeen
291+ started := i.titleGenerationStarted
292+ i.mu.Unlock()
293+ if !seen || !started {
294+ t.Fatal("loaded session was eligible for a new title")
295+ }
296+}
@@ -0,0 +1,296 @@
1+package modes
2+
3+import (
4+ "context"
5+ "errors"
6+ "strings"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/patriceckhart/zot/packages/core"
12+ "github.com/patriceckhart/zot/packages/provider"
13+)
14+
15+type interactiveTitleClient struct {
16+ mu sync.Mutex
17+ requests []provider.Request
18+ title string
19+ titleErr error
20+}
21+
22+func (c *interactiveTitleClient) Name() string { return "interactive-title-test" }
23+
24+func (c *interactiveTitleClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
25+ c.mu.Lock()
26+ c.requests = append(c.requests, req)
27+ c.mu.Unlock()
28+ if req.System != "" && c.titleErr != nil {
29+ return nil, c.titleErr
30+ }
31+ out := make(chan provider.Event, 2)
32+ if req.System != "" {
33+ out <- provider.EventTextDelta{Delta: c.title}
34+ out <- provider.EventDone{Stop: provider.StopEnd}
35+ } else {
36+ out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
37+ Role: provider.RoleAssistant,
38+ Content: []provider.Content{provider.TextBlock{Text: "main response"}},
39+ }}
40+ }
41+ close(out)
42+ return out, nil
43+}
44+
45+func (c *interactiveTitleClient) requestCount() int {
46+ c.mu.Lock()
47+ defer c.mu.Unlock()
48+ return len(c.requests)
49+}
50+
51+func waitInteractiveIdle(t *testing.T, i *Interactive) {
52+ t.Helper()
53+ deadline := time.Now().Add(time.Second)
54+ for time.Now().Before(deadline) {
55+ i.mu.Lock()
56+ busy := i.busy
57+ i.mu.Unlock()
58+ if !busy {
59+ return
60+ }
61+ time.Sleep(time.Millisecond)
62+ }
63+ t.Fatal("interactive turn did not finish")
64+}
65+
66+func waitInteractiveTitle(t *testing.T, i *Interactive, want string) {
67+ t.Helper()
68+ deadline := time.Now().Add(time.Second)
69+ for time.Now().Before(deadline) {
70+ i.mu.Lock()
71+ got := i.sessionTitle
72+ i.mu.Unlock()
73+ if got == want {
74+ return
75+ }
76+ time.Sleep(time.Millisecond)
77+ }
78+ i.mu.Lock()
79+ got := i.sessionTitle
80+ i.mu.Unlock()
81+ t.Fatalf("session title = %q, want %q", got, want)
82+}
83+
84+func waitTerminalOutput(t *testing.T, term *alertTestTerminal, want string) {
85+ t.Helper()
86+ deadline := time.Now().Add(time.Second)
87+ for time.Now().Before(deadline) {
88+ if got := term.String(); got == want {
89+ return
90+ }
91+ time.Sleep(time.Millisecond)
92+ }
93+ t.Fatalf("terminal title output = %q, want %q", term.String(), want)
94+}
95+
96+func TestFirstRealPromptSetsAndPersistsTerminalTitle(t *testing.T) {
97+ term := &alertTestTerminal{}
98+ client := &interactiveTitleClient{title: "Fix login retries"}
99+ ag := core.NewAgent(client, "test-model", "", nil)
100+ persisted := make(chan string, 1)
101+ i := NewInteractive(InteractiveConfig{
102+ Agent: ag,
103+ Terminal: term,
104+ PersistTitle: func(title string) error {
105+ persisted <- title
106+ return nil
107+ },
108+ })
109+ i.markInteractiveStarted()
110+
111+ i.Submit("Please fix login retries")
112+ waitInteractiveIdle(t, i)
113+
114+ select {
115+ case got := <-persisted:
116+ if got != "Fix login retries" {
117+ t.Fatalf("persisted title = %q, want %q", got, "Fix login retries")
118+ }
119+ case <-time.After(time.Second):
120+ t.Fatal("title was not persisted")
121+ }
122+ waitTerminalOutput(t, term, "\x1b]0;zot: Fix login retries\x07")
123+ if got := len(ag.Messages()); got != 2 {
124+ t.Fatalf("transcript message count = %d, want user + assistant only", got)
125+ }
126+ if got := client.requestCount(); got != 2 {
127+ t.Fatalf("provider request count = %d, want main + hidden title", got)
128+ }
129+}
130+
131+func TestTitleFailureFallsBackWithoutFailingMainTurn(t *testing.T) {
132+ term := &alertTestTerminal{}
133+ client := &interactiveTitleClient{titleErr: errors.New("title provider unavailable")}
134+ ag := core.NewAgent(client, "test-model", "", nil)
135+ persisted := make(chan string, 1)
136+ i := NewInteractive(InteractiveConfig{
137+ Agent: ag,
138+ Terminal: term,
139+ PersistTitle: func(title string) error {
140+ persisted <- title
141+ return nil
142+ },
143+ })
144+ i.markInteractiveStarted()
145+
146+ prompt := "Please fix the login retry behavior"
147+ i.Submit(prompt)
148+ waitInteractiveIdle(t, i)
149+
150+ select {
151+ case got := <-persisted:
152+ if got != "Please fix the login retry behavior" {
153+ t.Fatalf("fallback title = %q, want prompt text", got)
154+ }
155+ case <-time.After(time.Second):
156+ t.Fatal("fallback title was not persisted")
157+ }
158+ if got := len(ag.Messages()); got != 2 {
159+ t.Fatalf("transcript message count = %d, want main turn only", got)
160+ }
161+}
162+
163+func TestSkillSlashPromptDoesNotConsumeTheFirstRealPromptTitle(t *testing.T) {
164+ term := &alertTestTerminal{}
165+ client := &interactiveTitleClient{title: "should not be used"}
166+ ag := core.NewAgent(client, "test-model", "", nil)
167+ i := NewInteractive(InteractiveConfig{Agent: ag, Terminal: term})
168+ i.markInteractiveStarted()
169+
170+ i.submitOrQueuePrompt(context.Background(), "expanded skill instructions")
171+ waitInteractiveIdle(t, i)
172+
173+ if got := client.requestCount(); got != 1 {
174+ t.Fatalf("provider request count = %d, want only the slash-expanded main request", got)
175+ }
176+ i.mu.Lock()
177+ seen := i.titleRealPromptSeen
178+ i.mu.Unlock()
179+ if seen {
180+ t.Fatal("slash-expanded prompt consumed the first real prompt title slot")
181+ }
182+}
183+
184+func TestStartupPreDoesNotConsumeTheFirstRealPromptTitle(t *testing.T) {
185+ term := &alertTestTerminal{}
186+ client := &interactiveTitleClient{title: "real prompt title"}
187+ ag := core.NewAgent(client, "test-model", "", nil)
188+ i := NewInteractive(InteractiveConfig{Agent: ag, Terminal: term})
189+ i.markInteractiveStarted()
190+ i.awaitingStartupPre = true
191+
192+ i.Submit("load startup resources")
193+ waitInteractiveIdle(t, i)
194+
195+ if got := client.requestCount(); got != 1 {
196+ t.Fatalf("startup pre request count = %d, want only the startup request", got)
197+ }
198+ i.mu.Lock()
199+ seen := i.titleRealPromptSeen
200+ i.mu.Unlock()
201+ if seen {
202+ t.Fatal("startup pre consumed the first real prompt title slot")
203+ }
204+}
205+
206+func TestDisabledTerminalTitlesSkipHiddenRequest(t *testing.T) {
207+ term := &alertTestTerminal{}
208+ disabled := false
209+ client := &interactiveTitleClient{title: "should not be used"}
210+ ag := core.NewAgent(client, "test-model", "", nil)
211+ i := NewInteractive(InteractiveConfig{Agent: ag, Terminal: term, TerminalTitleEnabled: &disabled})
212+ i.markInteractiveStarted()
213+
214+ i.startTurn(context.Background(), "do the work")
215+ waitInteractiveIdle(t, i)
216+
217+ if got := client.requestCount(); got != 1 {
218+ t.Fatalf("provider request count = %d, want only the main request", got)
219+ }
220+ if got := term.String(); got != "" {
221+ t.Fatalf("disabled title output = %q, want empty", got)
222+ }
223+}
224+
225+func TestFreshBranchTitleIgnoresCopiedPrefix(t *testing.T) {
226+ term := &alertTestTerminal{}
227+ client := &interactiveTitleClient{title: "new branch title"}
228+ ag := core.NewAgent(client, "test-model", "", nil)
229+ ag.SetMessages([]provider.Message{{
230+ Role: provider.RoleUser,
231+ Content: []provider.Content{provider.TextBlock{Text: "copied parent prompt"}},
232+ }})
233+ i := NewInteractive(InteractiveConfig{
234+ Agent: ag,
235+ Terminal: term,
236+ InitialSessionTitlePending: true,
237+ })
238+ i.markInteractiveStarted()
239+
240+ i.Submit("new branch work")
241+ waitInteractiveIdle(t, i)
242+
243+ if got := client.requestCount(); got != 2 {
244+ t.Fatalf("provider request count = %d, want main + hidden title", got)
245+ }
246+ waitInteractiveTitle(t, i, "new branch title")
247+ i.mu.Lock()
248+ seen := i.titleRealPromptSeen
249+ i.mu.Unlock()
250+ if !seen {
251+ t.Fatal("fresh branch did not record its first real prompt")
252+ }
253+}
254+
255+func TestDisablingTerminalTitlesRestoresNeutralTitle(t *testing.T) {
256+ term := &alertTestTerminal{}
257+ enabled := true
258+ i := NewInteractive(InteractiveConfig{
259+ Terminal: term,
260+ TerminalTitleEnabled: &enabled,
261+ InitialSessionTitle: "Existing work",
262+ })
263+ i.markInteractiveStarted()
264+ i.applySettingToggle("terminal_title_enabled", false)
265+
266+ got := term.String()
267+ if !strings.Contains(got, "\x1b]0;zot: Existing work\x07") || !strings.Contains(got, "\x1b]0;zot\x07") {
268+ t.Fatalf("terminal title output = %q, want existing and neutral title sequences", got)
269+ }
270+}
271+
272+func TestLoadedSessionTitleIsRestoredWithoutGeneration(t *testing.T) {
273+ term := &alertTestTerminal{}
274+ ag := core.NewAgent(nil, "test-model", "", nil)
275+ ag.SetMessages([]provider.Message{{
276+ Role: provider.RoleUser,
277+ Content: []provider.Content{provider.TextBlock{Text: "existing prompt"}},
278+ }})
279+ i := NewInteractive(InteractiveConfig{
280+ Agent: ag,
281+ Terminal: term,
282+ InitialSessionTitle: "Existing work",
283+ })
284+ i.markInteractiveStarted()
285+
286+ if got, want := term.String(), "\x1b]0;zot: Existing work\x07"; got != want {
287+ t.Fatalf("restored terminal title = %q, want %q", got, want)
288+ }
289+ i.mu.Lock()
290+ seen := i.titleRealPromptSeen
291+ started := i.titleGenerationStarted
292+ i.mu.Unlock()
293+ if !seen || !started {
294+ t.Fatal("loaded session was eligible for a new title")
295+ }
296+}
M packages/agent/settings_store.go +9 -0
@@ -50,6 +50,15 @@ func (configSettingsStore) SetTerminalAlertsEnabled(enabled bool) error {
5050 return SaveConfig(cfg)
5151 }
5252
53+func (configSettingsStore) SetTerminalTitleEnabled(enabled bool) error {
54+ cfg, err := LoadConfig()
55+ if err != nil {
56+ return err
57+ }
58+ cfg.TerminalTitleEnabled = &enabled
59+ return SaveConfig(cfg)
60+}
61+
5362 func (configSettingsStore) SetAutoSwarm(enabled bool) error {
5463 cfg, err := LoadConfig()
5564 if err != nil {
@@ -50,6 +50,15 @@ func (configSettingsStore) SetTerminalAlertsEnabled(enabled bool) error {
50 return SaveConfig(cfg)50 return SaveConfig(cfg)
51 }51 }
52 52
53+func (configSettingsStore) SetTerminalTitleEnabled(enabled bool) error {
54+ cfg, err := LoadConfig()
55+ if err != nil {
56+ return err
57+ }
58+ cfg.TerminalTitleEnabled = &enabled
59+ return SaveConfig(cfg)
60+}
61+
53 func (configSettingsStore) SetAutoSwarm(enabled bool) error {62 func (configSettingsStore) SetAutoSwarm(enabled bool) error {
54 cfg, err := LoadConfig()63 cfg, err := LoadConfig()
55 if err != nil {64 if err != nil {
M packages/agent/settings_store_test.go +21 -0
@@ -44,6 +44,27 @@ func TestConfigSettingsStorePersistsTerminalAlerts(t *testing.T) {
4444 }
4545 }
4646
47+func TestConfigSettingsStorePersistsTerminalTitle(t *testing.T) {
48+ t.Setenv("ZOT_HOME", t.TempDir())
49+ if err := SaveConfig(Config{Theme: "dark"}); err != nil {
50+ t.Fatal(err)
51+ }
52+
53+ if err := (configSettingsStore{}).SetTerminalTitleEnabled(false); err != nil {
54+ t.Fatal(err)
55+ }
56+ cfg, err := LoadConfig()
57+ if err != nil {
58+ t.Fatal(err)
59+ }
60+ if cfg.TerminalTitleEnabled == nil || *cfg.TerminalTitleEnabled {
61+ t.Fatal("terminal_title_enabled was not persisted as disabled")
62+ }
63+ if cfg.Theme != "dark" {
64+ t.Fatalf("unrelated config changed: theme = %q, want dark", cfg.Theme)
65+ }
66+}
67+
4768 func TestConfigSettingsStorePersistsJailByDefault(t *testing.T) {
4869 t.Setenv("ZOT_HOME", t.TempDir())
4970 if err := SaveConfig(Config{Theme: "dark"}); err != nil {
@@ -44,6 +44,27 @@ func TestConfigSettingsStorePersistsTerminalAlerts(t *testing.T) {
44 }44 }
45 }45 }
46 46
47+func TestConfigSettingsStorePersistsTerminalTitle(t *testing.T) {
48+ t.Setenv("ZOT_HOME", t.TempDir())
49+ if err := SaveConfig(Config{Theme: "dark"}); err != nil {
50+ t.Fatal(err)
51+ }
52+
53+ if err := (configSettingsStore{}).SetTerminalTitleEnabled(false); err != nil {
54+ t.Fatal(err)
55+ }
56+ cfg, err := LoadConfig()
57+ if err != nil {
58+ t.Fatal(err)
59+ }
60+ if cfg.TerminalTitleEnabled == nil || *cfg.TerminalTitleEnabled {
61+ t.Fatal("terminal_title_enabled was not persisted as disabled")
62+ }
63+ if cfg.Theme != "dark" {
64+ t.Fatalf("unrelated config changed: theme = %q, want dark", cfg.Theme)
65+ }
66+}
67+
47 func TestConfigSettingsStorePersistsJailByDefault(t *testing.T) {68 func TestConfigSettingsStorePersistsJailByDefault(t *testing.T) {
48 t.Setenv("ZOT_HOME", t.TempDir())69 t.Setenv("ZOT_HOME", t.TempDir())
49 if err := SaveConfig(Config{Theme: "dark"}); err != nil {70 if err := SaveConfig(Config{Theme: "dark"}); err != nil {
M packages/core/session.go +55 -15
@@ -21,9 +21,12 @@ import (
2121
2222 // Session is a JSONL-backed conversation transcript tied to a cwd.
2323 type Session struct {
24- ID string
25- Path string
26- Meta SessionMeta
24+ ID string
25+ Path string
26+ Meta SessionMeta
27+ // Title is the effective title after applying append-only rename rows.
28+ // Meta.Title remains the original meta-line value for compatibility.
29+ Title string
2730 writer *os.File
2831 buf *bufio.Writer
2932
@@ -71,6 +74,8 @@ type SessionMeta struct {
7174 // regular provider.Message value.
7275 type sessionLine struct {
7376 Type string `json:"type"`
77+ Title string `json:"title,omitempty"`
78+ Generated bool `json:"generated,omitempty"`
7479 Meta *SessionMeta `json:"meta,omitempty"`
7580 Message *provider.Message `json:"message,omitempty"`
7681 Messages *[]provider.Message `json:"messages,omitempty"`
@@ -96,6 +101,7 @@ type SessionUsageCheckpoint struct {
96101 // indices used by tree navigation and branching must agree.
97102 type SessionSnapshot struct {
98103 Meta SessionMeta
104+ Title string
99105 Messages []provider.Message
100106 UsageCheckpoints []SessionUsageCheckpoint
101107 }
@@ -243,6 +249,7 @@ func ReadSessionSnapshot(path string) (SessionSnapshot, error) {
243249 generation int
244250 }
245251 var rawCheckpoints []rawCheckpoint
252+ titleFromRename := false
246253
247254 err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error {
248255 var head sessionLineHead
@@ -266,6 +273,9 @@ func ReadSessionSnapshot(path string) (SessionSnapshot, error) {
266273 return fmt.Errorf("line %d: meta row has no session id", lineNo)
267274 }
268275 snapshot.Meta = *row.Meta
276+ if !titleFromRename && row.Meta.Title != "" {
277+ snapshot.Title = row.Meta.Title
278+ }
269279 sawMeta = true
270280
271281 case "message":
@@ -310,6 +320,8 @@ func ReadSessionSnapshot(path string) (SessionSnapshot, error) {
310320 if row.Title == nil {
311321 return fmt.Errorf("line %d: rename row has no title", lineNo)
312322 }
323+ titleFromRename = true
324+ snapshot.Title = *row.Title
313325
314326 default:
315327 return fmt.Errorf("line %d: unknown row type %q", lineNo, head.Type)
@@ -542,6 +554,7 @@ func OpenSession(path string) (*Session, []provider.Message, error) {
542554 ID: snapshot.Meta.ID,
543555 Path: path,
544556 Meta: snapshot.Meta,
557+ Title: NormalizeSessionTitle(snapshot.Title),
545558 writer: out,
546559 buf: bufio.NewWriter(out),
547560 }
@@ -599,13 +612,14 @@ type SessionSummary struct {
599612 BranchDepth int
600613 }
601614
602-// RenameSession updates the title field in the session's meta line.
603-// It rewrites the first line of the file (the meta line) with the
604-// updated title.
605-// RenameSession appends a rename line to the session file. This is
606-// safe even for the currently active session because it opens the
607-// file independently and appends (doesn't rewrite).
615+// RenameSession appends a sanitized rename line to the session file. This is
616+// safe even for the currently active session because it opens the file
617+// independently and appends (doesn't rewrite).
608618 func RenameSession(path, title string) error {
619+ title = NormalizeSessionTitle(title)
620+ if title == "" {
621+ return errors.New("session title is empty")
622+ }
609623 f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0o644)
610624 if err != nil {
611625 return err
@@ -679,7 +693,7 @@ func describeSession(path string) SessionSummary {
679693 s.Started = snapshot.Meta.Started
680694 s.Model = snapshot.Meta.Model
681695 s.Provider = snapshot.Meta.Provider
682- s.Title = snapshot.Meta.Title
696+ s.Title = NormalizeSessionTitle(snapshot.Title)
683697 s.MessageCount = len(snapshot.Messages)
684698 s.FirstUserText = firstUserTextFromMessages(snapshot.Messages)
685699 lastUser := lastUserText(snapshot.Messages)
@@ -696,6 +710,7 @@ func describeSession(path string) SessionSummary {
696710 // explicit rename after divergence keeps priority.
697711 messageIdx := 0
698712 renameMessageIdx := -1
713+ renameGenerated := false
699714 _ = func() error {
700715 f, err := os.Open(path)
701716 if err != nil {
@@ -716,11 +731,13 @@ func describeSession(path string) SessionSummary {
716731 }
717732 case "rename":
718733 var row struct {
719- Title string `json:"title"`
734+ Title string `json:"title"`
735+ Generated bool `json:"generated"`
720736 }
721737 if err := json.Unmarshal(line, &row); err == nil && row.Title != "" {
722- s.Title = row.Title
738+ s.Title = NormalizeSessionTitle(row.Title)
723739 renameMessageIdx = messageIdx
740+ renameGenerated = row.Generated
724741 }
725742 }
726743 return nil
@@ -730,9 +747,14 @@ func describeSession(path string) SessionSummary {
730747 // A rename written after the branch has diverged is user intent
731748 // and must win. Older generated branch titles were written at
732749 // fork creation time, before any post-fork prompt, so those are
733- // allowed to be replaced by the branch prompt fallback.
734- userRenamedAfterFork := renameMessageIdx > forkPoint
735- if !userRenamedAfterFork && branchPrompt != "" {
750+ // allowed to be replaced by the branch prompt fallback. Current
751+ // generated titles are marked so a fast hidden request that writes
752+ // before the first post-fork message still wins.
753+ explicitRename := !renameGenerated && renameMessageIdx > forkPoint
754+ if !renameGenerated && renameMessageIdx == forkPoint && s.Title != lastUser {
755+ explicitRename = true
756+ }
757+ if !renameGenerated && !explicitRename && branchPrompt != "" {
736758 s.Title = branchPrompt
737759 } else if s.Title == "" && lastUser != "" {
738760 s.Title = lastUser
@@ -910,6 +932,24 @@ func (s *Session) UpdateModel(providerName, model string) error {
910932 return nil
911933 }
912934
935+// UpdateTitle records a session title without adding anything to the
936+// conversation transcript. The title is kept in memory as well so the live
937+// session can restore it when the user switches sessions in the TUI.
938+func (s *Session) UpdateTitle(title string) error {
939+ if s == nil {
940+ return nil
941+ }
942+ title = NormalizeSessionTitle(title)
943+ if title == "" {
944+ return nil
945+ }
946+ if err := s.writeLine(sessionLine{Type: "rename", Title: title, Generated: true}); err != nil {
947+ return fmt.Errorf("update session title: %w", err)
948+ }
949+ s.Title = title
950+ return nil
951+}
952+
913953 // AppendUsage writes a usage row to the session.
914954 func (s *Session) AppendUsage(u, cum provider.Usage) error {
915955 if s == nil {
@@ -21,9 +21,12 @@ import (
21 21
22 // Session is a JSONL-backed conversation transcript tied to a cwd.22 // Session is a JSONL-backed conversation transcript tied to a cwd.
23 type Session struct {23 type Session struct {
24- ID string24+ ID string
25- Path string25+ Path string
26- Meta SessionMeta26+ Meta SessionMeta
27+ // Title is the effective title after applying append-only rename rows.
28+ // Meta.Title remains the original meta-line value for compatibility.
29+ Title string
27 writer *os.File30 writer *os.File
28 buf *bufio.Writer31 buf *bufio.Writer
29 32
@@ -71,6 +74,8 @@ type SessionMeta struct {
71 // regular provider.Message value.74 // regular provider.Message value.
72 type sessionLine struct {75 type sessionLine struct {
73 Type string `json:"type"`76 Type string `json:"type"`
77+ Title string `json:"title,omitempty"`
78+ Generated bool `json:"generated,omitempty"`
74 Meta *SessionMeta `json:"meta,omitempty"`79 Meta *SessionMeta `json:"meta,omitempty"`
75 Message *provider.Message `json:"message,omitempty"`80 Message *provider.Message `json:"message,omitempty"`
76 Messages *[]provider.Message `json:"messages,omitempty"`81 Messages *[]provider.Message `json:"messages,omitempty"`
@@ -96,6 +101,7 @@ type SessionUsageCheckpoint struct {
96 // indices used by tree navigation and branching must agree.101 // indices used by tree navigation and branching must agree.
97 type SessionSnapshot struct {102 type SessionSnapshot struct {
98 Meta SessionMeta103 Meta SessionMeta
104+ Title string
99 Messages []provider.Message105 Messages []provider.Message
100 UsageCheckpoints []SessionUsageCheckpoint106 UsageCheckpoints []SessionUsageCheckpoint
101 }107 }
@@ -243,6 +249,7 @@ func ReadSessionSnapshot(path string) (SessionSnapshot, error) {
243 generation int249 generation int
244 }250 }
245 var rawCheckpoints []rawCheckpoint251 var rawCheckpoints []rawCheckpoint
252+ titleFromRename := false
246 253
247 err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error {254 err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error {
248 var head sessionLineHead255 var head sessionLineHead
@@ -266,6 +273,9 @@ func ReadSessionSnapshot(path string) (SessionSnapshot, error) {
266 return fmt.Errorf("line %d: meta row has no session id", lineNo)273 return fmt.Errorf("line %d: meta row has no session id", lineNo)
267 }274 }
268 snapshot.Meta = *row.Meta275 snapshot.Meta = *row.Meta
276+ if !titleFromRename && row.Meta.Title != "" {
277+ snapshot.Title = row.Meta.Title
278+ }
269 sawMeta = true279 sawMeta = true
270 280
271 case "message":281 case "message":
@@ -310,6 +320,8 @@ func ReadSessionSnapshot(path string) (SessionSnapshot, error) {
310 if row.Title == nil {320 if row.Title == nil {
311 return fmt.Errorf("line %d: rename row has no title", lineNo)321 return fmt.Errorf("line %d: rename row has no title", lineNo)
312 }322 }
323+ titleFromRename = true
324+ snapshot.Title = *row.Title
313 325
314 default:326 default:
315 return fmt.Errorf("line %d: unknown row type %q", lineNo, head.Type)327 return fmt.Errorf("line %d: unknown row type %q", lineNo, head.Type)
@@ -542,6 +554,7 @@ func OpenSession(path string) (*Session, []provider.Message, error) {
542 ID: snapshot.Meta.ID,554 ID: snapshot.Meta.ID,
543 Path: path,555 Path: path,
544 Meta: snapshot.Meta,556 Meta: snapshot.Meta,
557+ Title: NormalizeSessionTitle(snapshot.Title),
545 writer: out,558 writer: out,
546 buf: bufio.NewWriter(out),559 buf: bufio.NewWriter(out),
547 }560 }
@@ -599,13 +612,14 @@ type SessionSummary struct {
599 BranchDepth int612 BranchDepth int
600 }613 }
601 614
602-// RenameSession updates the title field in the session's meta line.615+// RenameSession appends a sanitized rename line to the session file. This is
603-// It rewrites the first line of the file (the meta line) with the616+// safe even for the currently active session because it opens the file
604-// updated title.617+// independently and appends (doesn't rewrite).
605-// RenameSession appends a rename line to the session file. This is
606-// safe even for the currently active session because it opens the
607-// file independently and appends (doesn't rewrite).
608 func RenameSession(path, title string) error {618 func RenameSession(path, title string) error {
619+ title = NormalizeSessionTitle(title)
620+ if title == "" {
621+ return errors.New("session title is empty")
622+ }
609 f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0o644)623 f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0o644)
610 if err != nil {624 if err != nil {
611 return err625 return err
@@ -679,7 +693,7 @@ func describeSession(path string) SessionSummary {
679 s.Started = snapshot.Meta.Started693 s.Started = snapshot.Meta.Started
680 s.Model = snapshot.Meta.Model694 s.Model = snapshot.Meta.Model
681 s.Provider = snapshot.Meta.Provider695 s.Provider = snapshot.Meta.Provider
682- s.Title = snapshot.Meta.Title696+ s.Title = NormalizeSessionTitle(snapshot.Title)
683 s.MessageCount = len(snapshot.Messages)697 s.MessageCount = len(snapshot.Messages)
684 s.FirstUserText = firstUserTextFromMessages(snapshot.Messages)698 s.FirstUserText = firstUserTextFromMessages(snapshot.Messages)
685 lastUser := lastUserText(snapshot.Messages)699 lastUser := lastUserText(snapshot.Messages)
@@ -696,6 +710,7 @@ func describeSession(path string) SessionSummary {
696 // explicit rename after divergence keeps priority.710 // explicit rename after divergence keeps priority.
697 messageIdx := 0711 messageIdx := 0
698 renameMessageIdx := -1712 renameMessageIdx := -1
713+ renameGenerated := false
699 _ = func() error {714 _ = func() error {
700 f, err := os.Open(path)715 f, err := os.Open(path)
701 if err != nil {716 if err != nil {
@@ -716,11 +731,13 @@ func describeSession(path string) SessionSummary {
716 }731 }
717 case "rename":732 case "rename":
718 var row struct {733 var row struct {
719- Title string `json:"title"`734+ Title string `json:"title"`
735+ Generated bool `json:"generated"`
720 }736 }
721 if err := json.Unmarshal(line, &row); err == nil && row.Title != "" {737 if err := json.Unmarshal(line, &row); err == nil && row.Title != "" {
722- s.Title = row.Title738+ s.Title = NormalizeSessionTitle(row.Title)
723 renameMessageIdx = messageIdx739 renameMessageIdx = messageIdx
740+ renameGenerated = row.Generated
724 }741 }
725 }742 }
726 return nil743 return nil
@@ -730,9 +747,14 @@ func describeSession(path string) SessionSummary {
730 // A rename written after the branch has diverged is user intent747 // A rename written after the branch has diverged is user intent
731 // and must win. Older generated branch titles were written at748 // and must win. Older generated branch titles were written at
732 // fork creation time, before any post-fork prompt, so those are749 // fork creation time, before any post-fork prompt, so those are
733- // allowed to be replaced by the branch prompt fallback.750+ // allowed to be replaced by the branch prompt fallback. Current
734- userRenamedAfterFork := renameMessageIdx > forkPoint751+ // generated titles are marked so a fast hidden request that writes
735- if !userRenamedAfterFork && branchPrompt != "" {752+ // before the first post-fork message still wins.
753+ explicitRename := !renameGenerated && renameMessageIdx > forkPoint
754+ if !renameGenerated && renameMessageIdx == forkPoint && s.Title != lastUser {
755+ explicitRename = true
756+ }
757+ if !renameGenerated && !explicitRename && branchPrompt != "" {
736 s.Title = branchPrompt758 s.Title = branchPrompt
737 } else if s.Title == "" && lastUser != "" {759 } else if s.Title == "" && lastUser != "" {
738 s.Title = lastUser760 s.Title = lastUser
@@ -910,6 +932,24 @@ func (s *Session) UpdateModel(providerName, model string) error {
910 return nil932 return nil
911 }933 }
912 934
935+// UpdateTitle records a session title without adding anything to the
936+// conversation transcript. The title is kept in memory as well so the live
937+// session can restore it when the user switches sessions in the TUI.
938+func (s *Session) UpdateTitle(title string) error {
939+ if s == nil {
940+ return nil
941+ }
942+ title = NormalizeSessionTitle(title)
943+ if title == "" {
944+ return nil
945+ }
946+ if err := s.writeLine(sessionLine{Type: "rename", Title: title, Generated: true}); err != nil {
947+ return fmt.Errorf("update session title: %w", err)
948+ }
949+ s.Title = title
950+ return nil
951+}
952+
913 // AppendUsage writes a usage row to the session.953 // AppendUsage writes a usage row to the session.
914 func (s *Session) AppendUsage(u, cum provider.Usage) error {954 func (s *Session) AppendUsage(u, cum provider.Usage) error {
915 if s == nil {955 if s == nil {
M packages/core/session_portable.go +1 -12
@@ -220,6 +220,7 @@ func ImportSession(srcPath, root, cwd, version string) (string, error) {
220220 Provider: snapshot.Meta.Provider,
221221 Started: time.Now().UTC(),
222222 Version: version,
223+ Title: snapshot.Title,
223224 }
224225 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &importMeta})
225226 if err != nil {
@@ -400,18 +401,6 @@ func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hi
400401 }
401402 }
402403
403- if branchTitle := lastUserText(snapshot.Messages[:limit]); branchTitle != "" {
404- line, err := json.Marshal(map[string]string{"type": "rename", "title": branchTitle})
405- if err != nil {
406- return "", fmt.Errorf("branch: marshal title: %w", err)
407- }
408- if _, err := bw.Write(line); err != nil {
409- return "", err
410- }
411- if err := bw.WriteByte('\n'); err != nil {
412- return "", err
413- }
414- }
415404 if err := bw.Flush(); err != nil {
416405 return "", err
417406 }
@@ -220,6 +220,7 @@ func ImportSession(srcPath, root, cwd, version string) (string, error) {
220 Provider: snapshot.Meta.Provider,220 Provider: snapshot.Meta.Provider,
221 Started: time.Now().UTC(),221 Started: time.Now().UTC(),
222 Version: version,222 Version: version,
223+ Title: snapshot.Title,
223 }224 }
224 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &importMeta})225 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &importMeta})
225 if err != nil {226 if err != nil {
@@ -400,18 +401,6 @@ func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hi
400 }401 }
401 }402 }
402 403
403- if branchTitle := lastUserText(snapshot.Messages[:limit]); branchTitle != "" {
404- line, err := json.Marshal(map[string]string{"type": "rename", "title": branchTitle})
405- if err != nil {
406- return "", fmt.Errorf("branch: marshal title: %w", err)
407- }
408- if _, err := bw.Write(line); err != nil {
409- return "", err
410- }
411- if err := bw.WriteByte('\n'); err != nil {
412- return "", err
413- }
414- }
415 if err := bw.Flush(); err != nil {404 if err := bw.Flush(); err != nil {
416 return "", err405 return "", err
417 }406 }
M packages/core/session_portable_test.go +33 -0
@@ -364,6 +364,39 @@ func TestBranchSessionTitleUsesFirstPromptAfterForkWhenBranchDiverges(t *testing
364364 }
365365 }
366366
367+func TestGeneratedBranchTitleWinsWhenWrittenBeforeBranchPrompt(t *testing.T) {
368+ root := t.TempDir()
369+ cwd := "/project"
370+ parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
371+ if err != nil {
372+ t.Fatal(err)
373+ }
374+ _ = parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}})
375+ _ = parent.Close()
376+
377+ branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
378+ if err != nil {
379+ t.Fatalf("BranchSession: %v", err)
380+ }
381+ branch, _, err := OpenSession(branchPath)
382+ if err != nil {
383+ t.Fatal(err)
384+ }
385+ if err := branch.UpdateTitle("AI branch title"); err != nil {
386+ t.Fatal(err)
387+ }
388+ if err := branch.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "how are you?"}}}); err != nil {
389+ t.Fatal(err)
390+ }
391+ if err := branch.Close(); err != nil {
392+ t.Fatal(err)
393+ }
394+
395+ if summary := describeSession(branchPath); summary.Title != "AI branch title" {
396+ t.Errorf("branch title: want %q, got %q", "AI branch title", summary.Title)
397+ }
398+}
399+
367400 func TestRenamedBranchKeepsExplicitTitle(t *testing.T) {
368401 root := t.TempDir()
369402 cwd := "/project"
@@ -364,6 +364,39 @@ func TestBranchSessionTitleUsesFirstPromptAfterForkWhenBranchDiverges(t *testing
364 }364 }
365 }365 }
366 366
367+func TestGeneratedBranchTitleWinsWhenWrittenBeforeBranchPrompt(t *testing.T) {
368+ root := t.TempDir()
369+ cwd := "/project"
370+ parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
371+ if err != nil {
372+ t.Fatal(err)
373+ }
374+ _ = parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}})
375+ _ = parent.Close()
376+
377+ branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
378+ if err != nil {
379+ t.Fatalf("BranchSession: %v", err)
380+ }
381+ branch, _, err := OpenSession(branchPath)
382+ if err != nil {
383+ t.Fatal(err)
384+ }
385+ if err := branch.UpdateTitle("AI branch title"); err != nil {
386+ t.Fatal(err)
387+ }
388+ if err := branch.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "how are you?"}}}); err != nil {
389+ t.Fatal(err)
390+ }
391+ if err := branch.Close(); err != nil {
392+ t.Fatal(err)
393+ }
394+
395+ if summary := describeSession(branchPath); summary.Title != "AI branch title" {
396+ t.Errorf("branch title: want %q, got %q", "AI branch title", summary.Title)
397+ }
398+}
399+
367 func TestRenamedBranchKeepsExplicitTitle(t *testing.T) {400 func TestRenamedBranchKeepsExplicitTitle(t *testing.T) {
368 root := t.TempDir()401 root := t.TempDir()
369 cwd := "/project"402 cwd := "/project"
A packages/core/title.go +133 -0
@@ -0,0 +1,133 @@
1+package core
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+ "unicode"
8+
9+ "github.com/patriceckhart/zot/packages/provider"
10+)
11+
12+// SessionTitleMaxChars is the maximum length of an automatically generated
13+// session title. The limit is measured in Unicode code points so a title never
14+// cuts a UTF-8 sequence in half.
15+const SessionTitleMaxChars = 40
16+
17+const sessionTitleMaxOutputTokens = 16
18+const sessionTitlePromptMaxChars = 12000
19+
20+const sessionTitleSystemPrompt = `You create concise titles for coding sessions.
21+Return only the subject title for the user's task. Maximum 40 characters. Use one
22+line. Do not include quotes, Markdown, explanations, or trailing punctuation.`
23+
24+// GenerateSessionTitle asks the provider for a short title without touching a
25+// conversation transcript. It is intentionally separate from Agent.Prompt:
26+// title generation is UI/session metadata, not an assistant turn.
27+func GenerateSessionTitle(ctx context.Context, client provider.Client, model, prompt string) (string, error) {
28+ if client == nil {
29+ return "", fmt.Errorf("title generation: provider client is nil")
30+ }
31+ if strings.TrimSpace(prompt) == "" {
32+ return "", fmt.Errorf("title generation: prompt is empty")
33+ }
34+
35+ promptRunes := []rune(prompt)
36+ if len(promptRunes) > sessionTitlePromptMaxChars {
37+ prompt = string(promptRunes[:sessionTitlePromptMaxChars])
38+ }
39+ stream, err := client.Stream(ctx, provider.Request{
40+ Model: model,
41+ System: sessionTitleSystemPrompt,
42+ Messages: []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: prompt}}}},
43+ MaxTokens: sessionTitleMaxOutputTokens,
44+ })
45+ if err != nil {
46+ return "", fmt.Errorf("title generation request: %w", err)
47+ }
48+
49+ var (
50+ text strings.Builder
51+ done bool
52+ finalErr error
53+ finalMsg provider.Message
54+ )
55+ streamOpen := true
56+ for streamOpen && !done {
57+ select {
58+ case <-ctx.Done():
59+ return "", fmt.Errorf("title generation stream: %w", ctx.Err())
60+ case event, ok := <-stream:
61+ if !ok {
62+ streamOpen = false
63+ continue
64+ }
65+ switch e := event.(type) {
66+ case provider.EventTextDelta:
67+ text.WriteString(e.Delta)
68+ case provider.EventDone:
69+ done = true
70+ finalErr = e.Err
71+ finalMsg = e.Message
72+ }
73+ }
74+ }
75+ if finalErr != nil {
76+ return "", fmt.Errorf("title generation stream: %w", finalErr)
77+ }
78+ if !done {
79+ return "", fmt.Errorf("title generation stream ended without a result")
80+ }
81+ if text.Len() == 0 {
82+ text.WriteString(textFromMessage(finalMsg))
83+ }
84+ if title := NormalizeSessionTitle(text.String()); title != "" {
85+ return title, nil
86+ }
87+ return "", fmt.Errorf("title generation returned an empty title")
88+}
89+
90+// NormalizeSessionTitle turns model or user text into one safe, compact
91+// session-title value. It is also used for local fallbacks before a title is
92+// sent to a terminal escape sequence.
93+func NormalizeSessionTitle(text string) string {
94+ text = strings.Map(func(r rune) rune {
95+ if unicode.IsControl(r) || r == 0x7f {
96+ return -1
97+ }
98+ return r
99+ }, text)
100+ text = strings.TrimSpace(text)
101+ text = strings.Trim(text, "\"'`")
102+ text = strings.TrimSpace(text)
103+ for _, prefix := range []string{"title:", "subject:"} {
104+ if len(text) >= len(prefix) && strings.EqualFold(text[:len(prefix)], prefix) {
105+ text = strings.TrimSpace(text[len(prefix):])
106+ break
107+ }
108+ }
109+ text = strings.Join(strings.Fields(text), " ")
110+ text = strings.Trim(text, "\"'`")
111+ text = strings.TrimSpace(text)
112+ if text == "" {
113+ return ""
114+ }
115+ runes := []rune(text)
116+ if len(runes) > SessionTitleMaxChars {
117+ if SessionTitleMaxChars == 1 {
118+ return string(runes[:1])
119+ }
120+ return string(runes[:SessionTitleMaxChars-1]) + "…"
121+ }
122+ return text
123+}
124+
125+func textFromMessage(message provider.Message) string {
126+ var text strings.Builder
127+ for _, content := range message.Content {
128+ if block, ok := content.(provider.TextBlock); ok {
129+ text.WriteString(block.Text)
130+ }
131+ }
132+ return text.String()
133+}
@@ -0,0 +1,133 @@
1+package core
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+ "unicode"
8+
9+ "github.com/patriceckhart/zot/packages/provider"
10+)
11+
12+// SessionTitleMaxChars is the maximum length of an automatically generated
13+// session title. The limit is measured in Unicode code points so a title never
14+// cuts a UTF-8 sequence in half.
15+const SessionTitleMaxChars = 40
16+
17+const sessionTitleMaxOutputTokens = 16
18+const sessionTitlePromptMaxChars = 12000
19+
20+const sessionTitleSystemPrompt = `You create concise titles for coding sessions.
21+Return only the subject title for the user's task. Maximum 40 characters. Use one
22+line. Do not include quotes, Markdown, explanations, or trailing punctuation.`
23+
24+// GenerateSessionTitle asks the provider for a short title without touching a
25+// conversation transcript. It is intentionally separate from Agent.Prompt:
26+// title generation is UI/session metadata, not an assistant turn.
27+func GenerateSessionTitle(ctx context.Context, client provider.Client, model, prompt string) (string, error) {
28+ if client == nil {
29+ return "", fmt.Errorf("title generation: provider client is nil")
30+ }
31+ if strings.TrimSpace(prompt) == "" {
32+ return "", fmt.Errorf("title generation: prompt is empty")
33+ }
34+
35+ promptRunes := []rune(prompt)
36+ if len(promptRunes) > sessionTitlePromptMaxChars {
37+ prompt = string(promptRunes[:sessionTitlePromptMaxChars])
38+ }
39+ stream, err := client.Stream(ctx, provider.Request{
40+ Model: model,
41+ System: sessionTitleSystemPrompt,
42+ Messages: []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: prompt}}}},
43+ MaxTokens: sessionTitleMaxOutputTokens,
44+ })
45+ if err != nil {
46+ return "", fmt.Errorf("title generation request: %w", err)
47+ }
48+
49+ var (
50+ text strings.Builder
51+ done bool
52+ finalErr error
53+ finalMsg provider.Message
54+ )
55+ streamOpen := true
56+ for streamOpen && !done {
57+ select {
58+ case <-ctx.Done():
59+ return "", fmt.Errorf("title generation stream: %w", ctx.Err())
60+ case event, ok := <-stream:
61+ if !ok {
62+ streamOpen = false
63+ continue
64+ }
65+ switch e := event.(type) {
66+ case provider.EventTextDelta:
67+ text.WriteString(e.Delta)
68+ case provider.EventDone:
69+ done = true
70+ finalErr = e.Err
71+ finalMsg = e.Message
72+ }
73+ }
74+ }
75+ if finalErr != nil {
76+ return "", fmt.Errorf("title generation stream: %w", finalErr)
77+ }
78+ if !done {
79+ return "", fmt.Errorf("title generation stream ended without a result")
80+ }
81+ if text.Len() == 0 {
82+ text.WriteString(textFromMessage(finalMsg))
83+ }
84+ if title := NormalizeSessionTitle(text.String()); title != "" {
85+ return title, nil
86+ }
87+ return "", fmt.Errorf("title generation returned an empty title")
88+}
89+
90+// NormalizeSessionTitle turns model or user text into one safe, compact
91+// session-title value. It is also used for local fallbacks before a title is
92+// sent to a terminal escape sequence.
93+func NormalizeSessionTitle(text string) string {
94+ text = strings.Map(func(r rune) rune {
95+ if unicode.IsControl(r) || r == 0x7f {
96+ return -1
97+ }
98+ return r
99+ }, text)
100+ text = strings.TrimSpace(text)
101+ text = strings.Trim(text, "\"'`")
102+ text = strings.TrimSpace(text)
103+ for _, prefix := range []string{"title:", "subject:"} {
104+ if len(text) >= len(prefix) && strings.EqualFold(text[:len(prefix)], prefix) {
105+ text = strings.TrimSpace(text[len(prefix):])
106+ break
107+ }
108+ }
109+ text = strings.Join(strings.Fields(text), " ")
110+ text = strings.Trim(text, "\"'`")
111+ text = strings.TrimSpace(text)
112+ if text == "" {
113+ return ""
114+ }
115+ runes := []rune(text)
116+ if len(runes) > SessionTitleMaxChars {
117+ if SessionTitleMaxChars == 1 {
118+ return string(runes[:1])
119+ }
120+ return string(runes[:SessionTitleMaxChars-1]) + "…"
121+ }
122+ return text
123+}
124+
125+func textFromMessage(message provider.Message) string {
126+ var text strings.Builder
127+ for _, content := range message.Content {
128+ if block, ok := content.(provider.TextBlock); ok {
129+ text.WriteString(block.Text)
130+ }
131+ }
132+ return text.String()
133+}
A packages/core/title_test.go +211 -0
@@ -0,0 +1,211 @@
1+package core
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "testing"
8+ "time"
9+
10+ "github.com/patriceckhart/zot/packages/provider"
11+)
12+
13+type titleTestClient struct {
14+ request provider.Request
15+ events []provider.Event
16+ err error
17+}
18+
19+func (c *titleTestClient) Name() string { return "title-test" }
20+
21+func (c *titleTestClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
22+ c.request = req
23+ if c.err != nil {
24+ return nil, c.err
25+ }
26+ out := make(chan provider.Event, len(c.events))
27+ for _, event := range c.events {
28+ out <- event
29+ }
30+ close(out)
31+ return out, nil
32+}
33+
34+type blockingTitleClient struct {
35+ started chan struct{}
36+}
37+
38+func (c *blockingTitleClient) Name() string { return "blocking-title" }
39+
40+func (c *blockingTitleClient) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Event, error) {
41+ close(c.started)
42+ out := make(chan provider.Event)
43+ go func() {
44+ <-ctx.Done()
45+ close(out)
46+ }()
47+ return out, nil
48+}
49+
50+func TestGenerateSessionTitleUsesAHiddenMinimalRequest(t *testing.T) {
51+ client := &titleTestClient{events: []provider.Event{
52+ provider.EventTextDelta{Delta: "Fix "},
53+ provider.EventTextDelta{Delta: "login flow"},
54+ provider.EventDone{Stop: provider.StopEnd},
55+ }}
56+
57+ got, err := GenerateSessionTitle(context.Background(), client, "test-model", "Please fix the login flow")
58+ if err != nil {
59+ t.Fatal(err)
60+ }
61+ if got != "Fix login flow" {
62+ t.Fatalf("title = %q, want %q", got, "Fix login flow")
63+ }
64+ if client.request.Model != "test-model" {
65+ t.Fatalf("model = %q, want test-model", client.request.Model)
66+ }
67+ if client.request.System == "" {
68+ t.Fatal("title request has no system instruction")
69+ }
70+ if len(client.request.Tools) != 0 {
71+ t.Fatalf("title request advertised %d tools, want none", len(client.request.Tools))
72+ }
73+ if client.request.MaxTokens != 16 {
74+ t.Fatalf("max tokens = %d, want 16", client.request.MaxTokens)
75+ }
76+ if len(client.request.Messages) != 1 || client.request.Messages[0].Role != provider.RoleUser {
77+ t.Fatalf("title request messages = %#v, want one user message", client.request.Messages)
78+ }
79+}
80+
81+func TestGenerateSessionTitleFallsBackToFinalMessageWhenNoDeltas(t *testing.T) {
82+ client := &titleTestClient{events: []provider.Event{
83+ provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
84+ Role: provider.RoleAssistant,
85+ Content: []provider.Content{provider.TextBlock{Text: "Title: \"Review API\""}},
86+ }},
87+ }}
88+
89+ got, err := GenerateSessionTitle(context.Background(), client, "test-model", "Review the API")
90+ if err != nil {
91+ t.Fatal(err)
92+ }
93+ if got != "Review API" {
94+ t.Fatalf("title = %q, want %q", got, "Review API")
95+ }
96+}
97+
98+func TestGenerateSessionTitlePropagatesStreamErrors(t *testing.T) {
99+ want := errors.New("provider unavailable")
100+ client := &titleTestClient{events: []provider.Event{
101+ provider.EventDone{Stop: provider.StopError, Err: want},
102+ }}
103+ if _, err := GenerateSessionTitle(context.Background(), client, "test-model", "Do work"); !errors.Is(err, want) {
104+ t.Fatalf("error = %v, want wrapped %v", err, want)
105+ }
106+}
107+
108+func TestGenerateSessionTitleRejectsIncompleteStream(t *testing.T) {
109+ client := &titleTestClient{}
110+ if _, err := GenerateSessionTitle(context.Background(), client, "test-model", "Do work"); err == nil {
111+ t.Fatal("incomplete stream returned nil error")
112+ }
113+}
114+
115+func TestGenerateSessionTitleStopsWhenContextIsCancelled(t *testing.T) {
116+ ctx, cancel := context.WithCancel(context.Background())
117+ client := &blockingTitleClient{started: make(chan struct{})}
118+ result := make(chan error, 1)
119+ go func() {
120+ _, err := GenerateSessionTitle(ctx, client, "test-model", "Do work")
121+ result <- err
122+ }()
123+ <-client.started
124+ cancel()
125+
126+ select {
127+ case err := <-result:
128+ if !errors.Is(err, context.Canceled) {
129+ t.Fatalf("error = %v, want context canceled", err)
130+ }
131+ case <-time.After(time.Second):
132+ t.Fatal("title generation did not stop after context cancellation")
133+ }
134+}
135+
136+func TestNormalizeSessionTitleIsConciseAndSafe(t *testing.T) {
137+ got := NormalizeSessionTitle("\n Title: `A very long title that needs to be shortened because it is too long` ")
138+ if got != "A very long title that needs to be shor…" {
139+ t.Fatalf("normalized title = %q", got)
140+ }
141+ if len([]rune(got)) != SessionTitleMaxChars {
142+ t.Fatalf("normalized title rune count = %d, want %d", len([]rune(got)), SessionTitleMaxChars)
143+ }
144+}
145+
146+func TestOpenSessionRestoresAppendOnlyTitle(t *testing.T) {
147+ root := t.TempDir()
148+ session, err := NewSession(root, "/workspace", "test", "test-model", "test")
149+ if err != nil {
150+ t.Fatal(err)
151+ }
152+ if err := session.AppendMessage(provider.Message{
153+ Role: provider.RoleUser,
154+ Content: []provider.Content{provider.TextBlock{Text: "work"}},
155+ }); err != nil {
156+ t.Fatal(err)
157+ }
158+ if err := session.UpdateTitle("Fix login"); err != nil {
159+ t.Fatal(err)
160+ }
161+ path := session.Path
162+ if err := session.Close(); err != nil {
163+ t.Fatal(err)
164+ }
165+
166+ reopened, _, err := OpenSession(path)
167+ if err != nil {
168+ t.Fatal(err)
169+ }
170+ defer reopened.Close()
171+ if reopened.Title != "Fix login" {
172+ t.Fatalf("restored title = %q, want %q", reopened.Title, "Fix login")
173+ }
174+ if reopened.Meta.Title != "" {
175+ t.Fatalf("meta title = %q, want append-only meta title to remain empty", reopened.Meta.Title)
176+ }
177+}
178+
179+func TestUpdateTitleLeavesMemoryUnchangedWhenWriteFails(t *testing.T) {
180+ root := t.TempDir()
181+ session, err := NewSession(root, "/workspace", "test", "test-model", "test")
182+ if err != nil {
183+ t.Fatal(err)
184+ }
185+ if err := session.AppendMessage(provider.Message{
186+ Role: provider.RoleUser,
187+ Content: []provider.Content{provider.TextBlock{Text: "work"}},
188+ }); err != nil {
189+ t.Fatal(err)
190+ }
191+ if err := session.Close(); err != nil {
192+ t.Fatal(err)
193+ }
194+
195+ if err := session.UpdateTitle("unwritten title"); err == nil {
196+ t.Fatal("UpdateTitle returned nil after the session writer was closed")
197+ }
198+ if session.Title != "" {
199+ t.Fatalf("in-memory title = %q after failed write, want empty", session.Title)
200+ }
201+}
202+
203+func TestNormalizeSessionTitlePreservesUnicodeBoundaries(t *testing.T) {
204+ got := NormalizeSessionTitle("éééééééééééééééééééééééééééééééééééééééééééé")
205+ if len([]rune(got)) != SessionTitleMaxChars {
206+ t.Fatalf("rune count = %d, want %d", len([]rune(got)), SessionTitleMaxChars)
207+ }
208+ if !json.Valid([]byte(`"` + got + `"`)) {
209+ t.Fatalf("normalized unicode title is not valid UTF-8 JSON: %q", got)
210+ }
211+}
@@ -0,0 +1,211 @@
1+package core
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "testing"
8+ "time"
9+
10+ "github.com/patriceckhart/zot/packages/provider"
11+)
12+
13+type titleTestClient struct {
14+ request provider.Request
15+ events []provider.Event
16+ err error
17+}
18+
19+func (c *titleTestClient) Name() string { return "title-test" }
20+
21+func (c *titleTestClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
22+ c.request = req
23+ if c.err != nil {
24+ return nil, c.err
25+ }
26+ out := make(chan provider.Event, len(c.events))
27+ for _, event := range c.events {
28+ out <- event
29+ }
30+ close(out)
31+ return out, nil
32+}
33+
34+type blockingTitleClient struct {
35+ started chan struct{}
36+}
37+
38+func (c *blockingTitleClient) Name() string { return "blocking-title" }
39+
40+func (c *blockingTitleClient) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Event, error) {
41+ close(c.started)
42+ out := make(chan provider.Event)
43+ go func() {
44+ <-ctx.Done()
45+ close(out)
46+ }()
47+ return out, nil
48+}
49+
50+func TestGenerateSessionTitleUsesAHiddenMinimalRequest(t *testing.T) {
51+ client := &titleTestClient{events: []provider.Event{
52+ provider.EventTextDelta{Delta: "Fix "},
53+ provider.EventTextDelta{Delta: "login flow"},
54+ provider.EventDone{Stop: provider.StopEnd},
55+ }}
56+
57+ got, err := GenerateSessionTitle(context.Background(), client, "test-model", "Please fix the login flow")
58+ if err != nil {
59+ t.Fatal(err)
60+ }
61+ if got != "Fix login flow" {
62+ t.Fatalf("title = %q, want %q", got, "Fix login flow")
63+ }
64+ if client.request.Model != "test-model" {
65+ t.Fatalf("model = %q, want test-model", client.request.Model)
66+ }
67+ if client.request.System == "" {
68+ t.Fatal("title request has no system instruction")
69+ }
70+ if len(client.request.Tools) != 0 {
71+ t.Fatalf("title request advertised %d tools, want none", len(client.request.Tools))
72+ }
73+ if client.request.MaxTokens != 16 {
74+ t.Fatalf("max tokens = %d, want 16", client.request.MaxTokens)
75+ }
76+ if len(client.request.Messages) != 1 || client.request.Messages[0].Role != provider.RoleUser {
77+ t.Fatalf("title request messages = %#v, want one user message", client.request.Messages)
78+ }
79+}
80+
81+func TestGenerateSessionTitleFallsBackToFinalMessageWhenNoDeltas(t *testing.T) {
82+ client := &titleTestClient{events: []provider.Event{
83+ provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
84+ Role: provider.RoleAssistant,
85+ Content: []provider.Content{provider.TextBlock{Text: "Title: \"Review API\""}},
86+ }},
87+ }}
88+
89+ got, err := GenerateSessionTitle(context.Background(), client, "test-model", "Review the API")
90+ if err != nil {
91+ t.Fatal(err)
92+ }
93+ if got != "Review API" {
94+ t.Fatalf("title = %q, want %q", got, "Review API")
95+ }
96+}
97+
98+func TestGenerateSessionTitlePropagatesStreamErrors(t *testing.T) {
99+ want := errors.New("provider unavailable")
100+ client := &titleTestClient{events: []provider.Event{
101+ provider.EventDone{Stop: provider.StopError, Err: want},
102+ }}
103+ if _, err := GenerateSessionTitle(context.Background(), client, "test-model", "Do work"); !errors.Is(err, want) {
104+ t.Fatalf("error = %v, want wrapped %v", err, want)
105+ }
106+}
107+
108+func TestGenerateSessionTitleRejectsIncompleteStream(t *testing.T) {
109+ client := &titleTestClient{}
110+ if _, err := GenerateSessionTitle(context.Background(), client, "test-model", "Do work"); err == nil {
111+ t.Fatal("incomplete stream returned nil error")
112+ }
113+}
114+
115+func TestGenerateSessionTitleStopsWhenContextIsCancelled(t *testing.T) {
116+ ctx, cancel := context.WithCancel(context.Background())
117+ client := &blockingTitleClient{started: make(chan struct{})}
118+ result := make(chan error, 1)
119+ go func() {
120+ _, err := GenerateSessionTitle(ctx, client, "test-model", "Do work")
121+ result <- err
122+ }()
123+ <-client.started
124+ cancel()
125+
126+ select {
127+ case err := <-result:
128+ if !errors.Is(err, context.Canceled) {
129+ t.Fatalf("error = %v, want context canceled", err)
130+ }
131+ case <-time.After(time.Second):
132+ t.Fatal("title generation did not stop after context cancellation")
133+ }
134+}
135+
136+func TestNormalizeSessionTitleIsConciseAndSafe(t *testing.T) {
137+ got := NormalizeSessionTitle("\n Title: `A very long title that needs to be shortened because it is too long` ")
138+ if got != "A very long title that needs to be shor…" {
139+ t.Fatalf("normalized title = %q", got)
140+ }
141+ if len([]rune(got)) != SessionTitleMaxChars {
142+ t.Fatalf("normalized title rune count = %d, want %d", len([]rune(got)), SessionTitleMaxChars)
143+ }
144+}
145+
146+func TestOpenSessionRestoresAppendOnlyTitle(t *testing.T) {
147+ root := t.TempDir()
148+ session, err := NewSession(root, "/workspace", "test", "test-model", "test")
149+ if err != nil {
150+ t.Fatal(err)
151+ }
152+ if err := session.AppendMessage(provider.Message{
153+ Role: provider.RoleUser,
154+ Content: []provider.Content{provider.TextBlock{Text: "work"}},
155+ }); err != nil {
156+ t.Fatal(err)
157+ }
158+ if err := session.UpdateTitle("Fix login"); err != nil {
159+ t.Fatal(err)
160+ }
161+ path := session.Path
162+ if err := session.Close(); err != nil {
163+ t.Fatal(err)
164+ }
165+
166+ reopened, _, err := OpenSession(path)
167+ if err != nil {
168+ t.Fatal(err)
169+ }
170+ defer reopened.Close()
171+ if reopened.Title != "Fix login" {
172+ t.Fatalf("restored title = %q, want %q", reopened.Title, "Fix login")
173+ }
174+ if reopened.Meta.Title != "" {
175+ t.Fatalf("meta title = %q, want append-only meta title to remain empty", reopened.Meta.Title)
176+ }
177+}
178+
179+func TestUpdateTitleLeavesMemoryUnchangedWhenWriteFails(t *testing.T) {
180+ root := t.TempDir()
181+ session, err := NewSession(root, "/workspace", "test", "test-model", "test")
182+ if err != nil {
183+ t.Fatal(err)
184+ }
185+ if err := session.AppendMessage(provider.Message{
186+ Role: provider.RoleUser,
187+ Content: []provider.Content{provider.TextBlock{Text: "work"}},
188+ }); err != nil {
189+ t.Fatal(err)
190+ }
191+ if err := session.Close(); err != nil {
192+ t.Fatal(err)
193+ }
194+
195+ if err := session.UpdateTitle("unwritten title"); err == nil {
196+ t.Fatal("UpdateTitle returned nil after the session writer was closed")
197+ }
198+ if session.Title != "" {
199+ t.Fatalf("in-memory title = %q after failed write, want empty", session.Title)
200+ }
201+}
202+
203+func TestNormalizeSessionTitlePreservesUnicodeBoundaries(t *testing.T) {
204+ got := NormalizeSessionTitle("éééééééééééééééééééééééééééééééééééééééééééé")
205+ if len([]rune(got)) != SessionTitleMaxChars {
206+ t.Fatalf("rune count = %d, want %d", len([]rune(got)), SessionTitleMaxChars)
207+ }
208+ if !json.Valid([]byte(`"` + got + `"`)) {
209+ t.Fatalf("normalized unicode title is not valid UTF-8 JSON: %q", got)
210+ }
211+}
M packages/tui/terminal.go +16 -0
@@ -4,7 +4,9 @@ import (
44 "io"
55 "os"
66 "path/filepath"
7+ "strings"
78 "time"
9+ "unicode"
810
911 "golang.org/x/term"
1012 )
@@ -51,6 +53,20 @@ func WriteBell(w io.Writer) error {
5153 return err
5254 }
5355
56+// SetTitle returns an OSC 0 sequence that sets the terminal's tab/window
57+// title. Control characters are removed from the payload so user- or
58+// model-provided text cannot inject another terminal control sequence.
59+func SetTitle(title string) string {
60+ var safe strings.Builder
61+ for _, r := range title {
62+ if unicode.IsControl(r) || r == 0x7f {
63+ continue
64+ }
65+ safe.WriteRune(r)
66+ }
67+ return "\x1b]0;" + safe.String() + "\x07"
68+}
69+
5470 func (p *ProcTerm) Size() (int, int) {
5571 w, h, err := term.GetSize(int(p.out.Fd()))
5672 if err != nil || w <= 0 || h <= 0 {
@@ -4,7 +4,9 @@ import (
4 "io"4 "io"
5 "os"5 "os"
6 "path/filepath"6 "path/filepath"
7+ "strings"
7 "time"8 "time"
9+ "unicode"
8 10
9 "golang.org/x/term"11 "golang.org/x/term"
10 )12 )
@@ -51,6 +53,20 @@ func WriteBell(w io.Writer) error {
51 return err53 return err
52 }54 }
53 55
56+// SetTitle returns an OSC 0 sequence that sets the terminal's tab/window
57+// title. Control characters are removed from the payload so user- or
58+// model-provided text cannot inject another terminal control sequence.
59+func SetTitle(title string) string {
60+ var safe strings.Builder
61+ for _, r := range title {
62+ if unicode.IsControl(r) || r == 0x7f {
63+ continue
64+ }
65+ safe.WriteRune(r)
66+ }
67+ return "\x1b]0;" + safe.String() + "\x07"
68+}
69+
54 func (p *ProcTerm) Size() (int, int) {70 func (p *ProcTerm) Size() (int, int) {
55 w, h, err := term.GetSize(int(p.out.Fd()))71 w, h, err := term.GetSize(int(p.out.Fd()))
56 if err != nil || w <= 0 || h <= 0 {72 if err != nil || w <= 0 || h <= 0 {
A packages/tui/title_test.go +23 -0
@@ -0,0 +1,23 @@
1+package tui
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestSetTitleUsesOSC0(t *testing.T) {
9+ if got, want := SetTitle("zot: fix login"), "\x1b]0;zot: fix login\x07"; got != want {
10+ t.Fatalf("SetTitle() = %q, want %q", got, want)
11+ }
12+}
13+
14+func TestSetTitleRemovesControlCharacters(t *testing.T) {
15+ got := SetTitle("safe\x1b]2;injected\x07\nnext")
16+ payload := got[len("\x1b]0;") : len(got)-1]
17+ if strings.ContainsRune(payload, '\x1b') || strings.ContainsRune(payload, '\x07') || strings.ContainsRune(payload, '\n') {
18+ t.Fatalf("title payload contains a control character: %q", got)
19+ }
20+ if got != "\x1b]0;safe]2;injectednext\x07" {
21+ t.Fatalf("sanitized title = %q", got)
22+ }
23+}
@@ -0,0 +1,23 @@
1+package tui
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestSetTitleUsesOSC0(t *testing.T) {
9+ if got, want := SetTitle("zot: fix login"), "\x1b]0;zot: fix login\x07"; got != want {
10+ t.Fatalf("SetTitle() = %q, want %q", got, want)
11+ }
12+}
13+
14+func TestSetTitleRemovesControlCharacters(t *testing.T) {
15+ got := SetTitle("safe\x1b]2;injected\x07\nnext")
16+ payload := got[len("\x1b]0;") : len(got)-1]
17+ if strings.ContainsRune(payload, '\x1b') || strings.ContainsRune(payload, '\x07') || strings.ContainsRune(payload, '\n') {
18+ t.Fatalf("title payload contains a control character: %q", got)
19+ }
20+ if got != "\x1b]0;safe]2;injectednext\x07" {
21+ t.Fatalf("sanitized title = %q", got)
22+ }
23+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close