Merge pull request #8 from bnema/feat/terminal-session-title
feat(tui): generate AI session terminal titles
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 | |||
| 290 | 290 | | `/unjail` | Allow tools to touch paths outside again. | | |
| 291 | 291 | | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). | | |
| 292 | 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 | 294 | | `/clear` | Clear the chat transcript. | | |
| 295 | 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 | 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 | 307 | ### `/session` | |
| 308 | 308 | ||
| @@ -392,10 +392,11 @@ zot discovers the common markdown/frontmatter profile layout from `~/.agents/age | |||
| 392 | 392 | ||
| 393 | 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 | 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 | 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 | 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 | 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 | 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. | |
| @@ -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 { | |||
| 1231 | 1231 | ||
| 1232 | 1232 | var sess *core.Session | |
| 1233 | 1233 | 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 | |
| 1236 | 1235 | if !args.NoSess && ag != nil { | |
| 1237 | 1236 | var sessErr error | |
| 1238 | 1237 | sess, sessErr = openOrCreateSession(args, r, ag, version) | |
| @@ -1258,6 +1257,9 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1258 | 1257 | r.Provider = candidate.provider | |
| 1259 | 1258 | 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 | 1264 | defer func() { | |
| 1263 | 1265 | persistMu.Lock() | |
| @@ -1402,11 +1404,11 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1402 | 1404 | persistMu.Unlock() | |
| 1403 | 1405 | return fmt.Errorf("session changed while loading; please try again") | |
| 1404 | 1406 | } | |
| 1405 | - | ||
| 1406 | 1407 | if oldSess != nil { | |
| 1407 | 1408 | _ = oldSess.Close() | |
| 1408 | 1409 | } | |
| 1409 | 1410 | sess = candidate.session | |
| 1411 | + | sessionTitlePending = candidate.session != nil && candidate.session.Meta.Parent != "" && candidate.session.Title == "" && candidate.fullMessageCount <= candidate.session.Meta.ForkPoint | |
| 1410 | 1412 | // The live agent only receives a compact resume window, but the | |
| 1411 | 1413 | // session file remains intact. Keep the persistence baseline at | |
| 1412 | 1414 | // 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 | 1588 | // Commit only after every fallible operation has succeeded. Close | |
| 1579 | 1589 | // the old session last so rollback never has to resurrect it. | |
| 1580 | 1590 | persistMu.Lock() | |
| @@ -1584,6 +1594,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1584 | 1594 | } | |
| 1585 | 1595 | sess = newSess | |
| 1586 | 1596 | sessBaselineMsgs = 0 | |
| 1597 | + | sessionTitlePending = false | |
| 1587 | 1598 | activeProvider = newProvider | |
| 1588 | 1599 | activeModel = newModel | |
| 1589 | 1600 | persistMu.Unlock() | |
| @@ -1693,6 +1704,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1693 | 1704 | Theme: theme, | |
| 1694 | 1705 | InlineImagesEnabled: initialCfg.InlineImagesEnabled, | |
| 1695 | 1706 | TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled, | |
| 1707 | + | TerminalTitleEnabled: initialCfg.TerminalTitleEnabled, | |
| 1696 | 1708 | AutoSwarmEnabled: initialCfg.AutoSwarmEnabled, | |
| 1697 | 1709 | FastMode: &fastMode, | |
| 1698 | 1710 | AutoCompactThreshold: initialCfg.AutoCompactThreshold, | |
| @@ -1732,8 +1744,15 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1732 | 1744 | UpdateInfoChan: updateCh, | |
| 1733 | 1745 | Sandbox: sharedSandbox, | |
| 1734 | 1746 | 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, | |
| 1737 | 1756 | OnStartupPreDone: func() { | |
| 1738 | 1757 | // entry.pre often installs skills/extensions; rediscover them | |
| 1739 | 1758 | // before the user starts a model turn. | |
| @@ -1852,6 +1871,8 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1852 | 1871 | // selection used by future resumes. The live agent has already | |
| 1853 | 1872 | // switched pairs, so keep the in-memory selection aligned even | |
| 1854 | 1873 | // when the append fails and report that persistence error below. | |
| 1874 | + | sessionTransitionMu.RLock() | |
| 1875 | + | defer sessionTransitionMu.RUnlock() | |
| 1855 | 1876 | persistMu.Lock() | |
| 1856 | 1877 | var sessionErr error | |
| 1857 | 1878 | if sess != nil { | |
| @@ -1881,6 +1902,45 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1881 | 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 | 1945 | extHooks.attachInteractive(iv) | |
| 1886 | 1946 | ||
| @@ -1231,8 +1231,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1231 | 1231 | ||||
| 1232 | var sess *core.Session | 1232 | var sess *core.Session | ||
| 1233 | var sessBaselineMsgs int // messages already on disk when current session opened | 1233 | var sessBaselineMsgs int // messages already on disk when current session opened | ||
| 1234 | - | // persistMu also guards sess + sessBaselineMsgs, so a session swap | 1234 | + | 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 error | 1236 | 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.provider | 1257 | r.Provider = candidate.provider | ||
| 1259 | r.Model = candidate.model | 1258 | 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.session | 1410 | 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 the | 1412 | // The live agent only receives a compact resume window, but the | ||
| 1411 | // session file remains intact. Keep the persistence baseline at | 1413 | // session file remains intact. Keep the persistence baseline at | ||
| 1412 | // the original on-disk message count so future turns append after | 1414 | // 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. Close | 1588 | // 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 = newSess | 1595 | sess = newSess | ||
| 1586 | sessBaselineMsgs = 0 | 1596 | sessBaselineMsgs = 0 | ||
| 1597 | + | sessionTitlePending = false | |||
| 1587 | activeProvider = newProvider | 1598 | activeProvider = newProvider | ||
| 1588 | activeModel = newModel | 1599 | 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 them | 1757 | // 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 already | 1871 | // selection used by future resumes. The live agent has already | ||
| 1853 | // switched pairs, so keep the in-memory selection aligned even | 1872 | // 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 error | 1877 | 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 { | |||
| 63 | 63 | // alerts. Toggle from /settings. | |
| 64 | 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 | 71 | // AutoSwarmEnabled lets the main agent spawn background sub-agents | |
| 67 | 72 | // for parallel sub-tasks via a built-in swarm_spawn tool. Off by | |
| 68 | 73 | // 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-agents | 71 | // AutoSwarmEnabled lets the main agent spawn background sub-agents | ||
| 67 | // for parallel sub-tasks via a built-in swarm_spawn tool. Off by | 72 | // 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 { | |||
| 63 | 63 | // agent and extensions. nil means enabled; false disables them. | |
| 64 | 64 | 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 | 70 | // AutoSwarmEnabled mirrors the persisted config flag at startup so | |
| 67 | 71 | // the /settings dialog can render the current state without | |
| 68 | 72 | // re-reading config.json on every open. | |
| @@ -267,6 +271,29 @@ type InteractiveConfig struct { | |||
| 267 | 271 | // write a new meta row so resume picks up the same model. | |
| 268 | 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 | 297 | OnAssistant func(m provider.Message) | |
| 271 | 298 | OnToolResult func(id string, r core.ToolResult) | |
| 272 | 299 | ||
| @@ -373,6 +400,10 @@ type terminalAlertsSettingsStore interface { | |||
| 373 | 400 | SetTerminalAlertsEnabled(enabled bool) error | |
| 374 | 401 | } | |
| 375 | 402 | ||
| 403 | + | type terminalTitleSettingsStore interface { | |
| 404 | + | SetTerminalTitleEnabled(enabled bool) error | |
| 405 | + | } | |
| 406 | + | ||
| 376 | 407 | type showInstructionsSettingsStore interface { | |
| 377 | 408 | SetShowInstructionsAtStartup(enabled bool) error | |
| 378 | 409 | } | |
| @@ -569,6 +600,18 @@ type Interactive struct { | |||
| 569 | 600 | // after this point and reverts to plain text after. | |
| 570 | 601 | 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 | 615 | // extNotes are one-shot styled lines pushed by extensions via | |
| 573 | 616 | // Notify / Display. They live above the editor (just below the | |
| 574 | 617 | // transcript) until cleared by /clear or another reset. | |
| @@ -721,6 +764,15 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 721 | 764 | 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 | 776 | if cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled { | |
| 725 | 777 | i.applyAutoSwarmTool(true) | |
| 726 | 778 | } | |
| @@ -736,6 +788,8 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 736 | 788 | return err | |
| 737 | 789 | } | |
| 738 | 790 | defer restore() | |
| 791 | + | i.markInteractiveStarted() | |
| 792 | + | defer i.cancelSessionTitle() | |
| 739 | 793 | defer func() { | |
| 740 | 794 | if i.telegramBridge != nil { | |
| 741 | 795 | i.telegramBridge.Stop() | |
| @@ -2254,10 +2308,29 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2254 | 2308 | i.sessionDialog.Close() | |
| 2255 | 2309 | 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 | 2319 | act := i.sessionDialog.HandleKey(k) | |
| 2258 | 2320 | if act.Select { | |
| 2259 | 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 | 2334 | // Always request a redraw after handling a key here: when esc | |
| 2262 | 2335 | // closes the picker, the overlay-close detection in the render | |
| 2263 | 2336 | // 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 | 2909 | if i.telegramBridge != nil && i.telegramBridge.Active() { | |
| 2837 | 2910 | go i.telegramBridge.OnUserTyped(text) | |
| 2838 | 2911 | } | |
| 2912 | + | i.maybeStartSessionTitle(ctx, text) | |
| 2839 | 2913 | // If a turn is already in flight, queue this prompt inside the | |
| 2840 | 2914 | // agent loop so it is delivered at the next safe model-call | |
| 2841 | 2915 | // boundary instead of waiting for the whole run to finish. | |
| @@ -3145,7 +3219,17 @@ func (i *Interactive) Submit(text string) { | |||
| 3145 | 3219 | i.startShellEscape(i.runCtx, cmd) | |
| 3146 | 3220 | 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 | 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 | 3344 | i.cfg.Provider = provider | |
| 3261 | 3345 | 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 | 3353 | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 3263 | 3354 | i.toolOrder = nil | |
| 3264 | 3355 | i.toolGate = map[string]int{} | |
| @@ -3266,6 +3357,9 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin | |||
| 3266 | 3357 | i.parkedTurn = 0 | |
| 3267 | 3358 | i.statusErr = "" | |
| 3268 | 3359 | i.mu.Unlock() | |
| 3360 | + | if titleCancel != nil { | |
| 3361 | + | titleCancel() | |
| 3362 | + | } | |
| 3269 | 3363 | i.fileSuggest.Reset() | |
| 3270 | 3364 | i.fileSuggest.SetCWD(cwd) | |
| 3271 | 3365 | i.invalidate() | |
| @@ -3312,6 +3406,9 @@ func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) { | |||
| 3312 | 3406 | i.invalidate() | |
| 3313 | 3407 | return | |
| 3314 | 3408 | } | |
| 3409 | + | i.mu.Unlock() | |
| 3410 | + | i.maybeStartSessionTitle(i.runCtx, text) | |
| 3411 | + | i.mu.Lock() | |
| 3315 | 3412 | if i.busy { | |
| 3316 | 3413 | // Queue text only; images are dropped for queued prompts. Compaction | |
| 3317 | 3414 | // 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 | 3612 | terminalAlerts := terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled) | |
| 3613 | + | terminalTitles := terminalTitleEnabled(i.cfg.TerminalTitleEnabled) | |
| 3516 | 3614 | autoSwarm := false | |
| 3517 | 3615 | if i.cfg.AutoSwarmEnabled != nil { | |
| 3518 | 3616 | autoSwarm = *i.cfg.AutoSwarmEnabled | |
| @@ -3634,6 +3732,12 @@ func (i *Interactive) openSettingsDialog() { | |||
| 3634 | 3732 | desc: "ring the terminal when the main session needs attention or an extension raises an alert", | |
| 3635 | 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 | 3742 | key: "auto_swarm_enabled", | |
| 3639 | 3743 | label: "auto-swarm", | |
| @@ -4005,6 +4109,35 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||
| 4005 | 4109 | i.statusOK = "terminal alerts " + onOff(value) | |
| 4006 | 4110 | i.statusErr = "" | |
| 4007 | 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 | 4141 | case "auto_swarm_enabled": | |
| 4009 | 4142 | val := value | |
| 4010 | 4143 | i.cfg.AutoSwarmEnabled = &val | |
| @@ -5367,18 +5500,23 @@ func (i *Interactive) applySessionSelection(path string) { | |||
| 5367 | 5500 | i.statusErr = "" | |
| 5368 | 5501 | i.mu.Unlock() | |
| 5369 | 5502 | i.invalidate() | |
| 5503 | + | i.markSessionTitleSwitching() | |
| 5370 | 5504 | ||
| 5371 | 5505 | go func() { | |
| 5372 | 5506 | err := i.cfg.LoadSession(path) | |
| 5373 | - | i.mu.Lock() | |
| 5374 | - | defer i.mu.Unlock() | |
| 5375 | - | i.sessionLoading = false | |
| 5376 | 5507 | if err != nil { | |
| 5508 | + | i.restoreFailedSessionTitle() | |
| 5509 | + | i.mu.Lock() | |
| 5510 | + | i.sessionLoading = false | |
| 5377 | 5511 | i.statusErr = err.Error() | |
| 5378 | 5512 | i.statusOK = "" | |
| 5513 | + | i.mu.Unlock() | |
| 5379 | 5514 | i.invalidate() | |
| 5380 | 5515 | return | |
| 5381 | 5516 | } | |
| 5517 | + | i.restoreLoadedSessionTitle() | |
| 5518 | + | i.mu.Lock() | |
| 5519 | + | i.sessionLoading = false | |
| 5382 | 5520 | i.statusOK = "resumed session: " + path | |
| 5383 | 5521 | i.statusErr = "" | |
| 5384 | 5522 | i.parkedTurn = 0 | |
| @@ -5408,6 +5546,7 @@ func (i *Interactive) applySessionSelection(path string) { | |||
| 5408 | 5546 | i.view.TailLimit = 0 | |
| 5409 | 5547 | } | |
| 5410 | 5548 | } | |
| 5549 | + | i.mu.Unlock() | |
| 5411 | 5550 | i.invalidate() | |
| 5412 | 5551 | }() | |
| 5413 | 5552 | } | |
| @@ -7151,13 +7290,16 @@ func (i *Interactive) doSessionImport(src string) { | |||
| 7151 | 7290 | i.invalidate() | |
| 7152 | 7291 | return | |
| 7153 | 7292 | } | |
| 7293 | + | i.markSessionTitleSwitching() | |
| 7154 | 7294 | if err := i.cfg.LoadSession(newPath); err != nil { | |
| 7295 | + | i.restoreFailedSessionTitle() | |
| 7155 | 7296 | i.mu.Lock() | |
| 7156 | 7297 | i.statusErr = "import: load failed: " + err.Error() | |
| 7157 | 7298 | i.mu.Unlock() | |
| 7158 | 7299 | i.invalidate() | |
| 7159 | 7300 | return | |
| 7160 | 7301 | } | |
| 7302 | + | i.restoreLoadedSessionTitle() | |
| 7161 | 7303 | i.mu.Lock() | |
| 7162 | 7304 | i.statusOK = "imported and switched to session " + friendlyPath(newPath) | |
| 7163 | 7305 | i.statusErr = "" | |
| @@ -7347,7 +7489,9 @@ func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo in | |||
| 7347 | 7489 | i.setSessionTreeError("tree: " + err.Error()) | |
| 7348 | 7490 | return | |
| 7349 | 7491 | } | |
| 7492 | + | i.markSessionTitleSwitching() | |
| 7350 | 7493 | if err := i.cfg.LoadSession(newPath); err != nil { | |
| 7494 | + | i.restoreFailedSessionTitle() | |
| 7351 | 7495 | i.setSessionTreeError("tree: checkout failed: " + err.Error()) | |
| 7352 | 7496 | return | |
| 7353 | 7497 | } | |
| @@ -7355,6 +7499,8 @@ func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo in | |||
| 7355 | 7499 | // LoadSession is intentionally the old func(string) error callback: the | |
| 7356 | 7500 | // CLI and embedders already own agent/session swapping. Refresh the view | |
| 7357 | 7501 | // from the swapped agent here instead of requiring a new callback result. | |
| 7502 | + | i.resetSessionTitleForFreshBranch() | |
| 7503 | + | i.scrollToBottom() | |
| 7358 | 7504 | i.mu.Lock() | |
| 7359 | 7505 | i.lastCtxInput = 0 | |
| 7360 | 7506 | if i.agent != nil { | |
| @@ -7422,13 +7568,16 @@ func (i *Interactive) applyForkSelection(msgIdx int) { | |||
| 7422 | 7568 | i.invalidate() | |
| 7423 | 7569 | return | |
| 7424 | 7570 | } | |
| 7571 | + | i.markSessionTitleSwitching() | |
| 7425 | 7572 | if err := i.cfg.LoadSession(newPath); err != nil { | |
| 7573 | + | i.restoreFailedSessionTitle() | |
| 7426 | 7574 | i.mu.Lock() | |
| 7427 | 7575 | i.statusErr = "fork: switch failed: " + err.Error() | |
| 7428 | 7576 | i.mu.Unlock() | |
| 7429 | 7577 | i.invalidate() | |
| 7430 | 7578 | return | |
| 7431 | 7579 | } | |
| 7580 | + | i.resetSessionTitleForFreshBranch() | |
| 7432 | 7581 | i.mu.Lock() | |
| 7433 | 7582 | i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath) | |
| 7434 | 7583 | 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 *bool | 64 | 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 so | 70 | // AutoSwarmEnabled mirrors the persisted config flag at startup so | ||
| 67 | // the /settings dialog can render the current state without | 71 | // 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) error | 400 | 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) error | 408 | 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.Time | 601 | 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 via | 615 | // extNotes are one-shot styled lines pushed by extensions via | ||
| 573 | // Notify / Display. They live above the editor (just below the | 616 | // 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 = initialResumeTailLimit | 764 | 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 err | 788 | 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 false | 2309 | 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 esc | 2334 | // Always request a redraw after handling a key here: when esc | ||
| 2262 | // closes the picker, the overlay-close detection in the render | 2335 | // closes the picker, the overlay-close detection in the render | ||
| 2263 | // pass needs to run so the tall dialog rows get repainted from | 2336 | // 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 the | 2913 | // If a turn is already in flight, queue this prompt inside the | ||
| 2840 | // agent loop so it is delivered at the next safe model-call | 2914 | // 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 | return | 3220 | 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 = provider | 3344 | i.cfg.Provider = provider | ||
| 3261 | i.cfg.Model = model | 3345 | 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 = nil | 3354 | 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 = 0 | 3357 | 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 | return | 3407 | 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. Compaction | 3413 | // Queue text only; images are dropped for queued prompts. Compaction | ||
| 3317 | // uses the host queue because it has no active agent loop to drain | 3414 | // 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 := false | 3614 | autoSwarm := false | ||
| 3517 | if i.cfg.AutoSwarmEnabled != nil { | 3615 | if i.cfg.AutoSwarmEnabled != nil { | ||
| 3518 | autoSwarm = *i.cfg.AutoSwarmEnabled | 3616 | 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 := value | 4142 | val := value | ||
| 4010 | i.cfg.AutoSwarmEnabled = &val | 4143 | 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 | return | 5515 | return | ||
| 5381 | } | 5516 | } | ||
| 5517 | + | i.restoreLoadedSessionTitle() | |||
| 5518 | + | i.mu.Lock() | |||
| 5519 | + | i.sessionLoading = false | |||
| 5382 | i.statusOK = "resumed session: " + path | 5520 | i.statusOK = "resumed session: " + path | ||
| 5383 | i.statusErr = "" | 5521 | i.statusErr = "" | ||
| 5384 | i.parkedTurn = 0 | 5522 | i.parkedTurn = 0 | ||
| @@ -5408,6 +5546,7 @@ func (i *Interactive) applySessionSelection(path string) { | |||||
| 5408 | i.view.TailLimit = 0 | 5546 | 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 | return | 7291 | 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 | return | 7300 | 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 | return | 7490 | 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 | return | 7496 | 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: the | 7499 | // LoadSession is intentionally the old func(string) error callback: the | ||
| 7356 | // CLI and embedders already own agent/session swapping. Refresh the view | 7500 | // 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 = 0 | 7505 | 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 | return | 7569 | 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 | return | 7578 | 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 { | |||
| 34 | 34 | ||
| 35 | 35 | // sessionDialogAction is returned by HandleKey. | |
| 36 | 36 | 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 | |
| 41 | 43 | } | |
| 42 | 44 | ||
| 43 | 45 | func newSessionDialog() *sessionDialog { return &sessionDialog{} } | |
| @@ -239,15 +241,22 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | |||
| 239 | 241 | if d.renaming { | |
| 240 | 242 | switch k.Kind { | |
| 241 | 243 | 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 | + | } | |
| 247 | 256 | } | |
| 248 | 257 | d.renaming = false | |
| 249 | 258 | d.rename = "" | |
| 250 | - | return sessionDialogAction{Renamed: true} | |
| 259 | + | return sessionDialogAction{Renamed: renamed, Path: path, RenameTitle: title, Err: renameErr} | |
| 251 | 260 | case tui.KeyEsc: | |
| 252 | 261 | d.renaming = false | |
| 253 | 262 | 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 bool | 37 | + | Select bool |
| 38 | - | Path string | 38 | + | Path string |
| 39 | - | Close bool | 39 | + | Close bool |
| 40 | - | Renamed bool | 40 | + | 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].Path | 246 | + | renamed := false |
| 245 | - | _ = core.RenameSession(path, title) | 247 | + | var renameErr error |
| 246 | - | d.sessions[d.cursor].Title = title | 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 | + | } | |||
| 247 | } | 256 | } | ||
| 248 | d.renaming = false | 257 | 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 = false | 261 | 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 { | |||
| 50 | 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 | 62 | func (configSettingsStore) SetAutoSwarm(enabled bool) error { | |
| 54 | 63 | cfg, err := LoadConfig() | |
| 55 | 64 | 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) { | |||
| 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 | 68 | func TestConfigSettingsStorePersistsJailByDefault(t *testing.T) { | |
| 48 | 69 | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 49 | 70 | 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 ( | |||
| 21 | 21 | ||
| 22 | 22 | // Session is a JSONL-backed conversation transcript tied to a cwd. | |
| 23 | 23 | 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 | |
| 27 | 30 | writer *os.File | |
| 28 | 31 | buf *bufio.Writer | |
| 29 | 32 | ||
| @@ -71,6 +74,8 @@ type SessionMeta struct { | |||
| 71 | 74 | // regular provider.Message value. | |
| 72 | 75 | type sessionLine struct { | |
| 73 | 76 | Type string `json:"type"` | |
| 77 | + | Title string `json:"title,omitempty"` | |
| 78 | + | Generated bool `json:"generated,omitempty"` | |
| 74 | 79 | Meta *SessionMeta `json:"meta,omitempty"` | |
| 75 | 80 | Message *provider.Message `json:"message,omitempty"` | |
| 76 | 81 | Messages *[]provider.Message `json:"messages,omitempty"` | |
| @@ -96,6 +101,7 @@ type SessionUsageCheckpoint struct { | |||
| 96 | 101 | // indices used by tree navigation and branching must agree. | |
| 97 | 102 | type SessionSnapshot struct { | |
| 98 | 103 | Meta SessionMeta | |
| 104 | + | Title string | |
| 99 | 105 | Messages []provider.Message | |
| 100 | 106 | UsageCheckpoints []SessionUsageCheckpoint | |
| 101 | 107 | } | |
| @@ -243,6 +249,7 @@ func ReadSessionSnapshot(path string) (SessionSnapshot, error) { | |||
| 243 | 249 | generation int | |
| 244 | 250 | } | |
| 245 | 251 | var rawCheckpoints []rawCheckpoint | |
| 252 | + | titleFromRename := false | |
| 246 | 253 | ||
| 247 | 254 | err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error { | |
| 248 | 255 | var head sessionLineHead | |
| @@ -266,6 +273,9 @@ func ReadSessionSnapshot(path string) (SessionSnapshot, error) { | |||
| 266 | 273 | return fmt.Errorf("line %d: meta row has no session id", lineNo) | |
| 267 | 274 | } | |
| 268 | 275 | snapshot.Meta = *row.Meta | |
| 276 | + | if !titleFromRename && row.Meta.Title != "" { | |
| 277 | + | snapshot.Title = row.Meta.Title | |
| 278 | + | } | |
| 269 | 279 | sawMeta = true | |
| 270 | 280 | ||
| 271 | 281 | case "message": | |
| @@ -310,6 +320,8 @@ func ReadSessionSnapshot(path string) (SessionSnapshot, error) { | |||
| 310 | 320 | if row.Title == nil { | |
| 311 | 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 | 326 | default: | |
| 315 | 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 | 554 | ID: snapshot.Meta.ID, | |
| 543 | 555 | Path: path, | |
| 544 | 556 | Meta: snapshot.Meta, | |
| 557 | + | Title: NormalizeSessionTitle(snapshot.Title), | |
| 545 | 558 | writer: out, | |
| 546 | 559 | buf: bufio.NewWriter(out), | |
| 547 | 560 | } | |
| @@ -599,13 +612,14 @@ type SessionSummary struct { | |||
| 599 | 612 | BranchDepth int | |
| 600 | 613 | } | |
| 601 | 614 | ||
| 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). | |
| 608 | 618 | func RenameSession(path, title string) error { | |
| 619 | + | title = NormalizeSessionTitle(title) | |
| 620 | + | if title == "" { | |
| 621 | + | return errors.New("session title is empty") | |
| 622 | + | } | |
| 609 | 623 | f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0o644) | |
| 610 | 624 | if err != nil { | |
| 611 | 625 | return err | |
| @@ -679,7 +693,7 @@ func describeSession(path string) SessionSummary { | |||
| 679 | 693 | s.Started = snapshot.Meta.Started | |
| 680 | 694 | s.Model = snapshot.Meta.Model | |
| 681 | 695 | s.Provider = snapshot.Meta.Provider | |
| 682 | - | s.Title = snapshot.Meta.Title | |
| 696 | + | s.Title = NormalizeSessionTitle(snapshot.Title) | |
| 683 | 697 | s.MessageCount = len(snapshot.Messages) | |
| 684 | 698 | s.FirstUserText = firstUserTextFromMessages(snapshot.Messages) | |
| 685 | 699 | lastUser := lastUserText(snapshot.Messages) | |
| @@ -696,6 +710,7 @@ func describeSession(path string) SessionSummary { | |||
| 696 | 710 | // explicit rename after divergence keeps priority. | |
| 697 | 711 | messageIdx := 0 | |
| 698 | 712 | renameMessageIdx := -1 | |
| 713 | + | renameGenerated := false | |
| 699 | 714 | _ = func() error { | |
| 700 | 715 | f, err := os.Open(path) | |
| 701 | 716 | if err != nil { | |
| @@ -716,11 +731,13 @@ func describeSession(path string) SessionSummary { | |||
| 716 | 731 | } | |
| 717 | 732 | case "rename": | |
| 718 | 733 | var row struct { | |
| 719 | - | Title string `json:"title"` | |
| 734 | + | Title string `json:"title"` | |
| 735 | + | Generated bool `json:"generated"` | |
| 720 | 736 | } | |
| 721 | 737 | if err := json.Unmarshal(line, &row); err == nil && row.Title != "" { | |
| 722 | - | s.Title = row.Title | |
| 738 | + | s.Title = NormalizeSessionTitle(row.Title) | |
| 723 | 739 | renameMessageIdx = messageIdx | |
| 740 | + | renameGenerated = row.Generated | |
| 724 | 741 | } | |
| 725 | 742 | } | |
| 726 | 743 | return nil | |
| @@ -730,9 +747,14 @@ func describeSession(path string) SessionSummary { | |||
| 730 | 747 | // A rename written after the branch has diverged is user intent | |
| 731 | 748 | // and must win. Older generated branch titles were written at | |
| 732 | 749 | // 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 != "" { | |
| 736 | 758 | s.Title = branchPrompt | |
| 737 | 759 | } else if s.Title == "" && lastUser != "" { | |
| 738 | 760 | s.Title = lastUser | |
| @@ -910,6 +932,24 @@ func (s *Session) UpdateModel(providerName, model string) error { | |||
| 910 | 932 | 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 | 953 | // AppendUsage writes a usage row to the session. | |
| 914 | 954 | func (s *Session) AppendUsage(u, cum provider.Usage) error { | |
| 915 | 955 | 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 string | 24 | + | ID string |
| 25 | - | Path string | 25 | + | Path string |
| 26 | - | Meta SessionMeta | 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 | |||
| 27 | writer *os.File | 30 | writer *os.File | ||
| 28 | buf *bufio.Writer | 31 | 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 SessionMeta | 103 | Meta SessionMeta | ||
| 104 | + | Title string | |||
| 99 | Messages []provider.Message | 105 | Messages []provider.Message | ||
| 100 | UsageCheckpoints []SessionUsageCheckpoint | 106 | UsageCheckpoints []SessionUsageCheckpoint | ||
| 101 | } | 107 | } | ||
| @@ -243,6 +249,7 @@ func ReadSessionSnapshot(path string) (SessionSnapshot, error) { | |||||
| 243 | generation int | 249 | generation int | ||
| 244 | } | 250 | } | ||
| 245 | var rawCheckpoints []rawCheckpoint | 251 | 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 sessionLineHead | 255 | 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.Meta | 275 | snapshot.Meta = *row.Meta | ||
| 276 | + | if !titleFromRename && row.Meta.Title != "" { | |||
| 277 | + | snapshot.Title = row.Meta.Title | |||
| 278 | + | } | |||
| 269 | sawMeta = true | 279 | 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 int | 612 | 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 the | 616 | + | // 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 err | 625 | return err | ||
| @@ -679,7 +693,7 @@ func describeSession(path string) SessionSummary { | |||||
| 679 | s.Started = snapshot.Meta.Started | 693 | s.Started = snapshot.Meta.Started | ||
| 680 | s.Model = snapshot.Meta.Model | 694 | s.Model = snapshot.Meta.Model | ||
| 681 | s.Provider = snapshot.Meta.Provider | 695 | s.Provider = snapshot.Meta.Provider | ||
| 682 | - | s.Title = snapshot.Meta.Title | 696 | + | 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 := 0 | 711 | messageIdx := 0 | ||
| 698 | renameMessageIdx := -1 | 712 | 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.Title | 738 | + | s.Title = NormalizeSessionTitle(row.Title) |
| 723 | renameMessageIdx = messageIdx | 739 | renameMessageIdx = messageIdx | ||
| 740 | + | renameGenerated = row.Generated | |||
| 724 | } | 741 | } | ||
| 725 | } | 742 | } | ||
| 726 | return nil | 743 | return nil | ||
| @@ -730,9 +747,14 @@ func describeSession(path string) SessionSummary { | |||||
| 730 | // A rename written after the branch has diverged is user intent | 747 | // A rename written after the branch has diverged is user intent | ||
| 731 | // and must win. Older generated branch titles were written at | 748 | // and must win. Older generated branch titles were written at | ||
| 732 | // fork creation time, before any post-fork prompt, so those are | 749 | // 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 > forkPoint | 751 | + | // 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 = branchPrompt | 758 | s.Title = branchPrompt | ||
| 737 | } else if s.Title == "" && lastUser != "" { | 759 | } else if s.Title == "" && lastUser != "" { | ||
| 738 | s.Title = lastUser | 760 | s.Title = lastUser | ||
| @@ -910,6 +932,24 @@ func (s *Session) UpdateModel(providerName, model string) error { | |||||
| 910 | return nil | 932 | 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) { | |||
| 220 | 220 | Provider: snapshot.Meta.Provider, | |
| 221 | 221 | Started: time.Now().UTC(), | |
| 222 | 222 | Version: version, | |
| 223 | + | Title: snapshot.Title, | |
| 223 | 224 | } | |
| 224 | 225 | metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &importMeta}) | |
| 225 | 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 | 404 | if err := bw.Flush(); err != nil { | |
| 416 | 405 | return "", err | |
| 417 | 406 | } | |
| @@ -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 "", err | 405 | return "", err | ||
| 417 | } | 406 | } | ||
M packages/core/session_portable_test.go +33 -0
| @@ -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 | 400 | func TestRenamedBranchKeepsExplicitTitle(t *testing.T) { | |
| 368 | 401 | root := t.TempDir() | |
| 369 | 402 | 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 ( | |||
| 4 | 4 | "io" | |
| 5 | 5 | "os" | |
| 6 | 6 | "path/filepath" | |
| 7 | + | "strings" | |
| 7 | 8 | "time" | |
| 9 | + | "unicode" | |
| 8 | 10 | ||
| 9 | 11 | "golang.org/x/term" | |
| 10 | 12 | ) | |
| @@ -51,6 +53,20 @@ func WriteBell(w io.Writer) error { | |||
| 51 | 53 | 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 | 70 | func (p *ProcTerm) Size() (int, int) { | |
| 55 | 71 | w, h, err := term.GetSize(int(p.out.Fd())) | |
| 56 | 72 | 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 err | 53 | 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 | + | } | |||