@sayknow-cli/coding-agent 0.6.4 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +25 -1
  2. package/README.md +2 -2
  3. package/dist/types/config/keybindings.d.ts +1 -1
  4. package/dist/types/config/settings-schema.d.ts +7 -3
  5. package/dist/types/i18n/messages/en.d.ts +16 -0
  6. package/dist/types/modes/components/session-selector.d.ts +7 -3
  7. package/dist/types/modes/components/sessions-dashboard.d.ts +2 -1
  8. package/dist/types/modes/components/user-message-selector.d.ts +5 -2
  9. package/dist/types/modes/components/user-message.d.ts +3 -1
  10. package/dist/types/modes/components/welcome.d.ts +49 -6
  11. package/dist/types/modes/controllers/command-controller.d.ts +1 -1
  12. package/dist/types/modes/controllers/selector-controller.d.ts +5 -0
  13. package/dist/types/modes/interactive-mode.d.ts +7 -1
  14. package/dist/types/modes/theme/defaults/index.d.ts +342 -0
  15. package/dist/types/modes/theme/theme.d.ts +5 -1
  16. package/dist/types/modes/types.d.ts +1 -1
  17. package/dist/types/session/session-manager.d.ts +9 -0
  18. package/dist/types/session/session-stars.d.ts +15 -0
  19. package/dist/types/tui/output-block.d.ts +12 -1
  20. package/dist/types/tui/utils.d.ts +1 -3
  21. package/dist/types/utils/session-title-generation.d.ts +6 -0
  22. package/dist/types/utils/title-generator.d.ts +11 -2
  23. package/package.json +7 -7
  24. package/scripts/generate-sdk-operation-inventory.ts +8 -0
  25. package/src/cli/session-picker.ts +4 -0
  26. package/src/config/keybindings.ts +1 -1
  27. package/src/config/settings-schema.ts +4 -3
  28. package/src/i18n/messages/de.messages.ts +4 -1
  29. package/src/i18n/messages/de.settings.ts +2 -0
  30. package/src/i18n/messages/de.ts +16 -0
  31. package/src/i18n/messages/en.ts +16 -0
  32. package/src/i18n/messages/es.messages.ts +4 -1
  33. package/src/i18n/messages/es.settings.ts +2 -0
  34. package/src/i18n/messages/es.ts +16 -0
  35. package/src/i18n/messages/fr.messages.ts +4 -1
  36. package/src/i18n/messages/fr.settings.ts +2 -0
  37. package/src/i18n/messages/fr.ts +16 -0
  38. package/src/i18n/messages/ja.messages.ts +4 -1
  39. package/src/i18n/messages/ja.settings.ts +2 -0
  40. package/src/i18n/messages/ja.ts +16 -0
  41. package/src/i18n/messages/ko.messages.ts +4 -1
  42. package/src/i18n/messages/ko.settings.ts +2 -0
  43. package/src/i18n/messages/ko.ts +16 -0
  44. package/src/i18n/messages/zh.messages.ts +2 -1
  45. package/src/i18n/messages/zh.settings.ts +2 -0
  46. package/src/i18n/messages/zh.ts +16 -0
  47. package/src/internal-urls/docs-index.generated.ts +10 -10
  48. package/src/lsp/render.ts +0 -1
  49. package/src/modes/components/session-selector.ts +71 -21
  50. package/src/modes/components/sessions-dashboard.ts +8 -5
  51. package/src/modes/components/settings-selector.ts +1 -0
  52. package/src/modes/components/status-line/presets.ts +5 -5
  53. package/src/modes/components/status-line/separators.ts +4 -0
  54. package/src/modes/components/tool-execution.ts +6 -19
  55. package/src/modes/components/tool-status-header.ts +103 -12
  56. package/src/modes/components/user-message-selector.ts +57 -15
  57. package/src/modes/components/user-message.ts +25 -15
  58. package/src/modes/components/welcome.ts +374 -478
  59. package/src/modes/controllers/command-controller.ts +56 -7
  60. package/src/modes/controllers/input-controller.ts +4 -0
  61. package/src/modes/controllers/selector-controller.ts +91 -10
  62. package/src/modes/interactive-mode.ts +101 -12
  63. package/src/modes/theme/defaults/glow-octopus.json +114 -0
  64. package/src/modes/theme/defaults/index.ts +6 -0
  65. package/src/modes/theme/defaults/ink-octopus.json +114 -0
  66. package/src/modes/theme/defaults/violet-octopus.json +114 -0
  67. package/src/modes/theme/theme.ts +18 -2
  68. package/src/modes/types.ts +1 -1
  69. package/src/prompts/system/title-system.md +1 -1
  70. package/src/sdk/protocol/operation-inventory.generated.json +40 -0
  71. package/src/session/agent-session.ts +12 -0
  72. package/src/session/internal/managed-session-scope.ts +10 -0
  73. package/src/session/session-manager.ts +26 -1
  74. package/src/session/session-stars.ts +104 -0
  75. package/src/slash-commands/builtin-registry.ts +104 -4
  76. package/src/tools/debug.ts +0 -1
  77. package/src/tools/fetch.ts +0 -1
  78. package/src/tui/output-block.ts +29 -61
  79. package/src/tui/utils.ts +1 -8
  80. package/src/utils/session-title-generation.ts +29 -0
  81. package/src/utils/title-generator.ts +46 -2
@@ -51,6 +51,12 @@ import {
51
51
  } from "../setup/provider-onboarding";
52
52
  import { parseThinkingLevel } from "../thinking";
53
53
  import { getDisplayChangelogEntries } from "../utils/changelog";
54
+ import {
55
+ beginSessionTitleGeneration,
56
+ invalidateSessionTitleGeneration,
57
+ isSessionTitleGenerationCurrent,
58
+ } from "../utils/session-title-generation";
59
+ import { buildConversationTitleInput, generateSessionTitle } from "../utils/title-generator";
54
60
  import { buildContextReportText } from "./helpers/context-report";
55
61
  import { buildFastStatusReport } from "./helpers/fast-status-report";
56
62
  import { formatDuration } from "./helpers/format";
@@ -113,6 +119,45 @@ function fastStatusRoleTargets(): Array<{ id: SkcModelAssignmentTargetId; label:
113
119
  }));
114
120
  }
115
121
 
122
+ async function regenerateSessionTitle(runtime: SlashCommandRuntime): Promise<SlashCommandResult> {
123
+ try {
124
+ const generation = beginSessionTitleGeneration(runtime.sessionManager);
125
+ const input = buildConversationTitleInput(runtime.session.messages);
126
+ if (!input) {
127
+ return usage("Nothing to summarize yet — pass a title: /rename <title>", runtime);
128
+ }
129
+
130
+ const generated = await generateSessionTitle(
131
+ input,
132
+ runtime.session.modelRegistry,
133
+ runtime.settings,
134
+ runtime.session.sessionId,
135
+ runtime.session.model,
136
+ provider => runtime.session.agent.metadataForProvider(provider),
137
+ );
138
+ // A newer prompt, message, or explicit rename landed while the model ran: drop this result.
139
+ if (
140
+ !isSessionTitleGenerationCurrent(runtime.sessionManager, generation) ||
141
+ buildConversationTitleInput(runtime.session.messages) !== input
142
+ )
143
+ return commandConsumed();
144
+ if (!generated) {
145
+ return usage("Could not generate a session title — pass one: /rename <title>", runtime);
146
+ }
147
+
148
+ const stored = await runtime.sessionManager.setSessionName(generated, "user");
149
+ if (!stored) {
150
+ await runtime.output("Session name not changed (a user-set name takes precedence).");
151
+ return commandConsumed();
152
+ }
153
+ await runtime.notifyTitleChanged?.();
154
+ await runtime.output(`Session renamed to ${generated}.`);
155
+ return commandConsumed();
156
+ } catch (err) {
157
+ return usage(`Rename failed: ${errorMessage(err)}`, runtime);
158
+ }
159
+ }
160
+
116
161
  function toSlashCommandRuntime(runtime: TuiSlashCommandRuntime): SlashCommandRuntime {
117
162
  const ctx = runtime.ctx;
118
163
  return {
@@ -139,6 +184,29 @@ function toSlashCommandRuntime(runtime: TuiSlashCommandRuntime): SlashCommandRun
139
184
  };
140
185
  }
141
186
 
187
+ async function updateSessionStar(
188
+ starred: boolean,
189
+ command: ParsedSlashCommand,
190
+ runtime: SlashCommandRuntime,
191
+ ): Promise<SlashCommandResult> {
192
+ if (command.args) return usage(`Usage: /${starred ? "star" : "unstar"}`, runtime);
193
+ if (!runtime.sessionManager.isPersisted()) {
194
+ await runtime.output("Session stars require a persisted session.");
195
+ return commandConsumed();
196
+ }
197
+ const changed = await runtime.sessionManager.setSessionStarred(starred);
198
+ await runtime.output(
199
+ changed
200
+ ? starred
201
+ ? "Session starred."
202
+ : "Session unstarred."
203
+ : starred
204
+ ? "Session is already starred."
205
+ : "Session is not starred.",
206
+ );
207
+ return commandConsumed();
208
+ }
209
+
142
210
  function parseProviderSetupSlashArgs(args: string): {
143
211
  preset?: string;
144
212
  compat?: string;
@@ -1619,6 +1687,24 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1619
1687
  await runtime.ctx.handleContributionPrepCommand(command.args || undefined);
1620
1688
  },
1621
1689
  },
1690
+ {
1691
+ name: "star",
1692
+ priority: 91,
1693
+ description: "Star the current session for easier discovery",
1694
+ // Accept args at dispatch so the handler can consume them as a usage error
1695
+ // instead of silently forwarding `/star ...` to the model.
1696
+ allowArgs: true,
1697
+ handle: (command, runtime) => updateSessionStar(true, command, runtime),
1698
+ },
1699
+ {
1700
+ name: "unstar",
1701
+ priority: 91,
1702
+ description: "Remove the star from the current session",
1703
+ // Accept args at dispatch so the handler can consume them as a usage error
1704
+ // instead of silently forwarding `/unstar ...` to the model.
1705
+ allowArgs: true,
1706
+ handle: (command, runtime) => updateSessionStar(false, command, runtime),
1707
+ },
1622
1708
  {
1623
1709
  name: "resume",
1624
1710
  priority: 92,
@@ -1742,13 +1828,27 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1742
1828
  await runtime.ctx.handleMemoryCommand(command.text);
1743
1829
  },
1744
1830
  },
1831
+ {
1832
+ name: "fork",
1833
+ description: "Choose an earlier prompt to continue in a new session",
1834
+ allowArgs: true,
1835
+ handleTui: async (command, runtime) => {
1836
+ runtime.ctx.editor.setText("");
1837
+ if (command.args.trim()) {
1838
+ runtime.ctx.showError("Usage: /fork");
1839
+ return;
1840
+ }
1841
+ runtime.ctx.showUserMessageSelector();
1842
+ },
1843
+ },
1745
1844
  {
1746
1845
  name: "rename",
1747
- description: "Rename the current session",
1748
- inlineHint: "<title>",
1846
+ description: "Rename the current session, or regenerate the title from the conversation",
1847
+ inlineHint: "[title]",
1749
1848
  allowArgs: true,
1750
1849
  handle: async (command, runtime) => {
1751
- if (!command.args) return usage("Usage: /rename <title>", runtime);
1850
+ if (!command.args) return regenerateSessionTitle(runtime);
1851
+ invalidateSessionTitleGeneration(runtime.sessionManager);
1752
1852
  const ok = await runtime.sessionManager.setSessionName(command.args, "user");
1753
1853
  if (!ok) {
1754
1854
  await runtime.output("Session name not changed (a user-set name takes precedence).");
@@ -1761,8 +1861,8 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1761
1861
  handleTui: async (command, runtime) => {
1762
1862
  const title = command.args.trim();
1763
1863
  if (!title) {
1764
- runtime.ctx.showError("Usage: /rename <title>");
1765
1864
  runtime.ctx.editor.setText("");
1865
+ await runtime.ctx.handleRenameCommand();
1766
1866
  return;
1767
1867
  }
1768
1868
  runtime.ctx.editor.setText("");
@@ -593,7 +593,6 @@ export const debugToolRenderer = {
593
593
  { label: theme.fg("toolTitle", "Output"), lines: displayedLines },
594
594
  ],
595
595
  width,
596
- applyBg: false,
597
596
  },
598
597
  theme,
599
598
  );
@@ -1546,7 +1546,6 @@ export function renderReadUrlResult(
1546
1546
  { label: uiTheme.fg("toolTitle", "Content Preview"), lines: contentPreviewLines },
1547
1547
  ],
1548
1548
  width,
1549
- applyBg: false,
1550
1549
  },
1551
1550
  uiTheme,
1552
1551
  );
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Bordered output container with optional header and sections.
2
+ * Railed output container with optional header and sections.
3
3
  */
4
4
  import {
5
5
  ImageProtocol,
@@ -13,7 +13,7 @@ import type { Theme } from "../modes/theme/theme";
13
13
  import { containsSixelSequence, getSixelLineMask } from "../utils/sixel";
14
14
  import type { State } from "./types";
15
15
  import type { RenderCache } from "./utils";
16
- import { getStateBgColor, Hasher, padToWidth, truncateToWidth } from "./utils";
16
+ import { Hasher, padToWidth, truncateToWidth } from "./utils";
17
17
 
18
18
  export interface OutputBlockOptions {
19
19
  header?: string;
@@ -21,17 +21,25 @@ export interface OutputBlockOptions {
21
21
  state?: State;
22
22
  sections?: Array<{ label?: string; lines: string[] }>;
23
23
  width: number;
24
- applyBg?: boolean;
25
24
  }
26
25
 
26
+ /**
27
+ * Render a tool output block as an open rail: a header line, then content hung
28
+ * off a single left rule in the state color. There is no enclosing box and no
29
+ * filled background in any state; the rail color alone carries running/success/error.
30
+ *
31
+ * ```
32
+ * ✓ Bash · 0.4s
33
+ * │ $ bun test
34
+ * ├ Output
35
+ * │ 12 pass
36
+ * ```
37
+ */
27
38
  export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): string[] {
28
- const { header, headerMeta, state, sections = [], width, applyBg = true } = options;
29
- const h = theme.boxSharp.horizontal;
30
- const v = theme.boxSharp.vertical;
31
- const cap = h.repeat(3);
39
+ const { header, headerMeta, state, sections = [], width } = options;
32
40
  const lineWidth = Math.max(0, width);
33
- // Border colors: running/pending use accent, success uses dim (gray), error/warning keep their colors
34
- const borderColor: "error" | "warning" | "accent" | "dim" =
41
+ // Rail colors: running/pending use accent, success recedes to dim, error/warning keep their colors.
42
+ const railColor: "error" | "warning" | "accent" | "dim" =
35
43
  state === "error"
36
44
  ? "error"
37
45
  : state === "warning"
@@ -39,53 +47,19 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
39
47
  : state === "running" || state === "pending"
40
48
  ? "accent"
41
49
  : "dim";
42
- const border = (text: string) => theme.fg(borderColor, text);
43
- const bgFn = (() => {
44
- if (!state || !applyBg) return undefined;
45
- const bgAnsi = theme.getBgAnsi(getStateBgColor(state));
46
- // Keep block background stable even if inner content contains SGR resets (e.g. "\x1b[0m"),
47
- // which would otherwise clear the outer background mid-line.
48
- return (text: string) => {
49
- const stabilized = text
50
- .replace(/\x1b\[(?:0)?m/g, m => `${m}${bgAnsi}`)
51
- .replace(/\x1b\[49m/g, m => `${m}${bgAnsi}`);
52
- return `${bgAnsi}${stabilized}\x1b[49m`;
53
- };
54
- })();
55
-
56
- const buildBarLine = (leftChar: string, rightChar: string, label?: string, meta?: string): string => {
57
- const left = border(`${leftChar}${cap}`);
58
- const right = border(rightChar);
59
- if (lineWidth <= 0) return left + right;
60
- const labelText = [label, meta].filter(Boolean).join(theme.sep.dot);
61
- const rawLabel = labelText ? ` ${labelText} ` : " ";
62
- const leftWidth = visibleWidth(left);
63
- const rightWidth = visibleWidth(right);
64
- const maxLabelWidth = Math.max(0, lineWidth - leftWidth - rightWidth);
65
- const trimmedLabel = truncateToWidth(rawLabel, maxLabelWidth);
66
- const labelWidth = visibleWidth(trimmedLabel);
67
- const fillCount = Math.max(0, lineWidth - leftWidth - labelWidth - rightWidth);
68
- return `${left}${trimmedLabel}${border(h.repeat(fillCount))}${right}`;
69
- };
70
-
71
- const contentPrefix = border(`${v} `);
72
- const contentSuffix = border(v);
73
- const contentWidth = Math.max(0, lineWidth - visibleWidth(contentPrefix) - visibleWidth(contentSuffix));
50
+ const rail = (text: string) => theme.fg(railColor, text);
74
51
  const lines: string[] = [];
52
+ const labelText = [header, headerMeta].filter(Boolean).join(theme.sep.dot);
53
+ if (labelText) lines.push(padToWidth(truncateToWidth(labelText, lineWidth), lineWidth));
75
54
 
76
- lines.push(
77
- padToWidth(buildBarLine(theme.boxSharp.topLeft, theme.boxSharp.topRight, header, headerMeta), lineWidth, bgFn),
78
- );
79
-
80
- const hasSections = sections.length > 0;
81
- const normalizedSections = hasSections ? sections : [{ lines: [] }];
55
+ const contentPrefix = rail(`${theme.boxSharp.vertical} `);
56
+ const contentWidth = Math.max(0, lineWidth - visibleWidth(contentPrefix));
82
57
 
83
- for (let i = 0; i < normalizedSections.length; i++) {
84
- const section = normalizedSections[i];
58
+ for (const section of sections) {
85
59
  if (section.label) {
86
- lines.push(
87
- padToWidth(buildBarLine(theme.boxSharp.teeRight, theme.boxSharp.teeLeft, section.label), lineWidth, bgFn),
88
- );
60
+ const tee = rail(`${theme.boxSharp.teeRight} `);
61
+ const label = truncateToWidth(theme.fg("dim", section.label), contentWidth);
62
+ lines.push(padToWidth(`${tee}${label}`, lineWidth));
89
63
  }
90
64
  const allLines = section.lines.flatMap(l => l.split("\n"));
91
65
  const fallbackActive = isTerminalGraphicsFallbackActive();
@@ -102,18 +76,13 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
102
76
  const wrappedLines = wrapTextWithAnsi(line.trimEnd(), contentWidth);
103
77
  for (const wrappedLine of wrappedLines) {
104
78
  const innerPadding = padding(Math.max(0, contentWidth - visibleWidth(wrappedLine)));
105
- const fullLine = `${contentPrefix}${wrappedLine}${innerPadding}${contentSuffix}`;
106
- lines.push(padToWidth(fullLine, lineWidth, bgFn));
79
+ lines.push(padToWidth(`${contentPrefix}${wrappedLine}${innerPadding}`, lineWidth));
107
80
  }
108
81
  }
109
82
  }
110
83
 
111
- const bottomLeft = border(`${theme.boxSharp.bottomLeft}${cap}`);
112
- const bottomRight = border(theme.boxSharp.bottomRight);
113
- const bottomFillCount = Math.max(0, lineWidth - visibleWidth(bottomLeft) - visibleWidth(bottomRight));
114
- const bottomLine = `${bottomLeft}${border(h.repeat(bottomFillCount))}${bottomRight}`;
115
- lines.push(padToWidth(bottomLine, lineWidth, bgFn));
116
-
84
+ // A block with neither header nor content still marks its place with a bare rail row.
85
+ if (lines.length === 0) lines.push(padToWidth(rail(theme.boxSharp.vertical), lineWidth));
117
86
  return lines;
118
87
  }
119
88
 
@@ -147,7 +116,6 @@ export class CachedOutputBlock {
147
116
  h.optional(options.header);
148
117
  h.optional(options.headerMeta);
149
118
  h.optional(options.state);
150
- h.bool(options.applyBg ?? true);
151
119
  h.bool(isTerminalGraphicsFallbackActive());
152
120
  if (options.sections) {
153
121
  for (const s of options.sections) {
package/src/tui/utils.ts CHANGED
@@ -2,8 +2,7 @@
2
2
  * Shared helpers for tool-rendered UI components.
3
3
  */
4
4
  import { padding, visibleWidth } from "@sayknow-cli/tui";
5
- import type { Theme, ThemeBg } from "../modes/theme/theme";
6
- import type { State } from "./types";
5
+ import type { Theme } from "../modes/theme/theme";
7
6
 
8
7
  export { Ellipsis, truncateToWidth } from "@sayknow-cli/tui";
9
8
 
@@ -95,9 +94,3 @@ export function padToWidth(text: string, width: number, bgFn?: (s: string) => st
95
94
  const padded = paddingNeeded > 0 ? text + padding(paddingNeeded) : text;
96
95
  return bgFn ? bgFn(padded) : padded;
97
96
  }
98
-
99
- export function getStateBgColor(state: State): ThemeBg {
100
- if (state === "success") return "toolSuccessBg";
101
- if (state === "error") return "toolErrorBg";
102
- return "toolPendingBg";
103
- }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * In-memory generation lease for asynchronous session-title regeneration.
3
+ *
4
+ * The lease is keyed by the session manager instance so TUI and ACP command
5
+ * dispatchers share the same ordering state without adding protocol fields to
6
+ * the persisted session format.
7
+ */
8
+ const generations = new WeakMap<object, number>();
9
+
10
+ function currentGeneration(owner: object): number {
11
+ return generations.get(owner) ?? 0;
12
+ }
13
+
14
+ /** Start a new regeneration and invalidate every older completion. */
15
+ export function beginSessionTitleGeneration(owner: object): number {
16
+ const generation = currentGeneration(owner) + 1;
17
+ generations.set(owner, generation);
18
+ return generation;
19
+ }
20
+
21
+ /** Invalidate pending regeneration after a newer session mutation is accepted. */
22
+ export function invalidateSessionTitleGeneration(owner: object): void {
23
+ generations.set(owner, currentGeneration(owner) + 1);
24
+ }
25
+
26
+ /** Return whether a regeneration still owns the latest generation lease. */
27
+ export function isSessionTitleGenerationCurrent(owner: object, generation: number): boolean {
28
+ return currentGeneration(owner) === generation;
29
+ }
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import * as path from "node:path";
5
5
 
6
+ import type { AgentMessage } from "@sayknow-cli/agent-core";
6
7
  import { type Api, type AssistantMessage, completeSimple, type Model, type Tool } from "@sayknow-cli/ai";
7
8
  import { logger, prompt } from "@sayknow-cli/utils";
8
9
  import type { ModelRegistry } from "../config/model-registry";
@@ -16,6 +17,9 @@ const TITLE_SYSTEM_PROMPT = prompt.render(titleSystemPrompt);
16
17
  const DEFAULT_TERMINAL_TITLE = "SKC";
17
18
  const TERMINAL_TITLE_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/g;
18
19
 
20
+ const MAX_TITLE_CONVERSATION_MESSAGES = 6;
21
+ const MAX_TITLE_MESSAGE_CHARS = 400;
22
+
19
23
  const MAX_INPUT_CHARS = 2000;
20
24
  const TITLE_MAX_TOKENS = 30;
21
25
  const REASONING_SAFE_MAX_TOKENS = 1024;
@@ -28,6 +32,44 @@ const SET_TITLE_TOOL_NAME = "set_title";
28
32
  const MAX_TITLE_CHARS = 80;
29
33
  const MAX_TITLE_WORDS = 12;
30
34
 
35
+ /**
36
+ * Build a bounded conversation digest for title regeneration. Regeneration
37
+ * reads several messages so a throwaway first prompt can be corrected, while
38
+ * the automatic first-message path intentionally uses only its initial input.
39
+ */
40
+ export function buildConversationTitleInput(messages: readonly AgentMessage[]): string | undefined {
41
+ const userMessages: string[] = [];
42
+ for (const message of messages) {
43
+ if (message.role !== "user") continue;
44
+ const content = message.content;
45
+ const text =
46
+ typeof content === "string"
47
+ ? content
48
+ : content
49
+ .filter(block => block.type === "text")
50
+ .map(block => block.text)
51
+ .join("\n");
52
+ const trimmed = text.trim();
53
+ if (trimmed) userMessages.push(trimmed);
54
+ }
55
+
56
+ if (userMessages.length === 0) return undefined;
57
+
58
+ const keptMessages =
59
+ userMessages.length > MAX_TITLE_CONVERSATION_MESSAGES
60
+ ? [userMessages[0]!, ...userMessages.slice(-(MAX_TITLE_CONVERSATION_MESSAGES - 1))]
61
+ : userMessages;
62
+ // Reserve separator and ellipsis room so the digest is already within the
63
+ // generator's MAX_INPUT_CHARS bound; its existing truncation is a no-op here.
64
+ const maxMessageChars = Math.min(
65
+ MAX_TITLE_MESSAGE_CHARS,
66
+ Math.floor((MAX_INPUT_CHARS - (keptMessages.length - 1) - keptMessages.length) / keptMessages.length),
67
+ );
68
+ return keptMessages
69
+ .map(message => (message.length > maxMessageChars ? `${message.slice(0, maxMessageChars)}…` : message))
70
+ .join("\n");
71
+ }
72
+
31
73
  const setTitleTool: Tool = {
32
74
  name: SET_TITLE_TOOL_NAME,
33
75
  description: "Set the generated session title.",
@@ -57,9 +99,11 @@ function getTitleModel(registry: ModelRegistry, settings: Settings, currentModel
57
99
  }
58
100
 
59
101
  /**
60
- * Generate a title for a session based on the first user message.
102
+ * Generate a title for a session based on the provided user-message input.
103
+ * Automatic titles pass the first user message; regeneration passes a bounded
104
+ * conversation digest.
61
105
  *
62
- * @param firstMessage The first user message
106
+ * @param firstMessage The first user message or bounded conversation digest
63
107
  * @param registry Model registry
64
108
  * @param settings Settings used to resolve the smol role
65
109
  * @param sessionId Optional session id for sticky API key selection