@sayknow-cli/coding-agent 0.3.11 → 0.3.12

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 +0 -21
  2. package/dist/types/build-metadata.d.ts +7 -0
  3. package/dist/types/capability/mcp.d.ts +3 -1
  4. package/dist/types/commands/mcp.d.ts +1 -1
  5. package/dist/types/config/keybindings.d.ts +2 -2
  6. package/dist/types/modes/acp/acp-event-mapper.d.ts +2 -0
  7. package/dist/types/modes/components/hook-selector.d.ts +1 -0
  8. package/dist/types/modes/components/status-line/types.d.ts +6 -0
  9. package/dist/types/modes/components/status-line.d.ts +1 -0
  10. package/dist/types/modes/components/welcome.d.ts +1 -0
  11. package/dist/types/modes/rpc/rpc-client.d.ts +7 -2
  12. package/dist/types/modes/utils/ui-helpers.d.ts +12 -0
  13. package/dist/types/notifications/telegram-daemon.d.ts +2 -0
  14. package/dist/types/runtime-mcp/config-writer.d.ts +4 -3
  15. package/dist/types/runtime-mcp/types.d.ts +4 -3
  16. package/dist/types/session/agent-session.d.ts +16 -0
  17. package/dist/types/session/session-manager.d.ts +1 -0
  18. package/dist/types/tools/skill.d.ts +9 -8
  19. package/dist/types/utils/linkedom.d.ts +4 -0
  20. package/dist/types/utils/pasted-image-path.d.ts +5 -3
  21. package/package.json +7 -7
  22. package/scripts/build-binary.ts +2 -43
  23. package/scripts/compile-args.ts +106 -0
  24. package/scripts/generate-docs-index.ts +1 -0
  25. package/src/build-metadata.ts +104 -0
  26. package/src/capability/mcp.ts +3 -1
  27. package/src/cli/mcp-cli.ts +108 -26
  28. package/src/cli.ts +21 -0
  29. package/src/commands/mcp.ts +8 -8
  30. package/src/commands/stats.ts +6 -2
  31. package/src/config/keybindings.ts +2 -2
  32. package/src/config/mcp-schema.json +21 -17
  33. package/src/config/model-profiles.ts +23 -23
  34. package/src/discovery/builtin.ts +14 -0
  35. package/src/discovery/mcp-json.ts +14 -0
  36. package/src/eval/js/executor.ts +7 -1
  37. package/src/internal-urls/docs-index.generated.ts +3 -2
  38. package/src/internal-urls/skc-protocol.ts +14 -1
  39. package/src/modes/acp/acp-agent.ts +72 -16
  40. package/src/modes/acp/acp-client-bridge.ts +27 -5
  41. package/src/modes/acp/acp-event-mapper.ts +39 -1
  42. package/src/modes/components/assistant-message.ts +1 -1
  43. package/src/modes/components/hook-selector.ts +4 -0
  44. package/src/modes/components/model-selector.ts +17 -6
  45. package/src/modes/components/session-selector.ts +2 -2
  46. package/src/modes/components/status-line/presets.ts +5 -19
  47. package/src/modes/components/status-line/segments.ts +14 -0
  48. package/src/modes/components/status-line/types.ts +6 -0
  49. package/src/modes/components/status-line.ts +13 -6
  50. package/src/modes/components/tool-execution.ts +1 -1
  51. package/src/modes/components/welcome.ts +4 -1
  52. package/src/modes/controllers/command-controller.ts +1 -1
  53. package/src/modes/controllers/event-controller.ts +9 -9
  54. package/src/modes/controllers/input-controller.ts +8 -2
  55. package/src/modes/controllers/selector-controller.ts +7 -3
  56. package/src/modes/interactive-mode.ts +12 -4
  57. package/src/modes/rpc/rpc-client.ts +30 -6
  58. package/src/modes/utils/ui-helpers.ts +154 -32
  59. package/src/notifications/telegram-daemon.ts +28 -2
  60. package/src/prompts/tools/skill.md +2 -2
  61. package/src/runtime-mcp/config-writer.ts +4 -3
  62. package/src/runtime-mcp/config.ts +1 -0
  63. package/src/runtime-mcp/types.ts +4 -3
  64. package/src/session/agent-session.ts +117 -3
  65. package/src/session/session-manager.ts +68 -40
  66. package/src/skc-runtime/state-runtime.ts +105 -12
  67. package/src/skc-runtime/workflow-manifest.generated.json +4 -28
  68. package/src/skc-runtime/workflow-manifest.ts +1 -7
  69. package/src/slash-commands/builtin-registry.ts +9 -4
  70. package/src/tools/bash-interactive.ts +9 -4
  71. package/src/tools/browser/readable.ts +2 -2
  72. package/src/tools/fetch.ts +5 -5
  73. package/src/tools/skill.ts +14 -11
  74. package/src/utils/linkedom.ts +14 -0
  75. package/src/utils/pasted-image-path.ts +23 -9
  76. package/src/web/scrapers/arxiv.ts +2 -2
  77. package/src/web/scrapers/go-pkg.ts +2 -2
  78. package/src/web/scrapers/iacr.ts +2 -2
  79. package/src/web/scrapers/readthedocs.ts +2 -2
  80. package/src/web/scrapers/twitter.ts +3 -2
  81. package/src/web/scrapers/wikipedia.ts +2 -2
@@ -22,18 +22,38 @@ import type {
22
22
  ClientBridgeTerminalHandle,
23
23
  } from "../../session/client-bridge";
24
24
 
25
+ type AcpPermissionMode = "auto" | "prompt" | "always-allow";
26
+
27
+ const ACP_PERMISSION_MODE_ENV = "SKC_ACP_PERMISSION_MODE";
28
+
29
+ function parseAcpPermissionMode(value: unknown): AcpPermissionMode {
30
+ if (value === "auto" || value === "prompt" || value === "always-allow") return value;
31
+ return "prompt";
32
+ }
33
+
34
+ /** Client metadata is authoritative; the process environment is only a fallback when that field is absent. */
35
+ function resolveAcpPermissionMode(clientCapabilities: ClientCapabilities | undefined): AcpPermissionMode {
36
+ const meta = clientCapabilities?._meta;
37
+ if (typeof meta === "object" && meta !== null) {
38
+ const skc = (meta as { skc?: unknown }).skc;
39
+ if (typeof skc === "object" && skc !== null && "permissionHandling" in skc) {
40
+ return parseAcpPermissionMode((skc as { permissionHandling?: unknown }).permissionHandling);
41
+ }
42
+ }
43
+ return parseAcpPermissionMode(process.env[ACP_PERMISSION_MODE_ENV]);
44
+ }
45
+
25
46
  export function createAcpClientBridge(
26
47
  connection: AgentSideConnection,
27
48
  sessionId: string,
28
49
  clientCapabilities: ClientCapabilities | undefined,
29
50
  ): ClientBridge {
51
+ const promptPermission = resolveAcpPermissionMode(clientCapabilities) === "prompt";
30
52
  const capabilities: ClientBridgeCapabilities = {
31
53
  readTextFile: clientCapabilities?.fs?.readTextFile === true,
32
54
  writeTextFile: clientCapabilities?.fs?.writeTextFile === true,
33
55
  terminal: clientCapabilities?.terminal === true,
34
- // Permission requests are always usable on the connection; gating is
35
- // the agent's policy choice rather than a client capability.
36
- requestPermission: true,
56
+ requestPermission: promptPermission,
37
57
  };
38
58
 
39
59
  const bridge: ClientBridge = { capabilities, deferAgentInitiatedTurns: true };
@@ -65,8 +85,10 @@ export function createAcpClientBridge(
65
85
  createTerminalHandle(connection, sessionId, params);
66
86
  }
67
87
 
68
- bridge.requestPermission = (toolCall, options, signal) =>
69
- requestPermission(connection, sessionId, toolCall, options, signal);
88
+ if (promptPermission) {
89
+ bridge.requestPermission = (toolCall, options, signal) =>
90
+ requestPermission(connection, sessionId, toolCall, options, signal);
91
+ }
70
92
 
71
93
  return bridge;
72
94
  }
@@ -27,6 +27,8 @@ interface AcpEventMapperOptions {
27
27
  * before emitting `ToolCallLocation` entries.
28
28
  */
29
29
  cwd?: string;
30
+ /** Phase to expose after compaction ends. Prompt-bound compaction resumes responding; idle maintenance returns idle. */
31
+ compactionEndPhase?: "responding" | "idle";
30
32
  }
31
33
 
32
34
  interface ContentArrayContainer {
@@ -236,8 +238,44 @@ export function mapAgentSessionEventToAcpSessionUpdates(
236
238
  case "turn_start":
237
239
  case "turn_end":
238
240
  case "message_start":
241
+ return [];
239
242
  case "auto_compaction_start":
240
- case "auto_compaction_end":
243
+ return [
244
+ toSessionNotification(sessionId, {
245
+ sessionUpdate: "session_info_update",
246
+ _meta: {
247
+ skcPhase: "compacting",
248
+ skcCompactionState: "start",
249
+ skcCompactionTrigger: event.reason,
250
+ skcCompactionAction: event.action,
251
+ running: true,
252
+ skcRunning: true,
253
+ },
254
+ }),
255
+ ];
256
+ case "auto_compaction_end": {
257
+ const phase = options.compactionEndPhase ?? "responding";
258
+ const running = phase !== "idle";
259
+ const meta: Record<string, unknown> = {
260
+ skcPhase: phase,
261
+ skcCompactionState: "end",
262
+ skcCompactionAction: event.action,
263
+ skcCompactionAborted: event.aborted,
264
+ skcCompactionWillRetry: event.willRetry,
265
+ running,
266
+ skcRunning: running,
267
+ };
268
+ if (event.skipped !== undefined) {
269
+ meta.skcCompactionSkipped = event.skipped;
270
+ }
271
+ if (event.errorMessage !== undefined) {
272
+ meta.skcCompactionErrorMessage = event.errorMessage;
273
+ }
274
+ if (event.continuationSkipReason !== undefined) {
275
+ meta.skcCompactionContinuationSkipReason = event.continuationSkipReason;
276
+ }
277
+ return [toSessionNotification(sessionId, { sessionUpdate: "session_info_update", _meta: meta })];
278
+ }
241
279
  case "auto_retry_start":
242
280
  case "auto_retry_end":
243
281
  case "retry_fallback_applied":
@@ -269,7 +269,7 @@ export class AssistantMessageComponent extends Container {
269
269
  displayImage.data,
270
270
  displayImage.mimeType,
271
271
  { fallbackColor: (text: string) => theme.fg("toolOutput", text) },
272
- resolveImageOptions(),
272
+ { ...resolveImageOptions(), refetch: () => displayImage.data },
273
273
  ),
274
274
  ),
275
275
  });
@@ -435,6 +435,10 @@ export class HookSelectorComponent extends Container {
435
435
  this.#updateList();
436
436
  }
437
437
 
438
+ hasActiveInlineInput(): boolean {
439
+ return this.#inlineEditor !== undefined;
440
+ }
441
+
438
442
  #updateList(): void {
439
443
  if (this.#wrapFocused && this.#focusAwareList) {
440
444
  this.#focusAwareList.setState(this.#options, this.#selectedIndex, this.#maxVisible);
@@ -119,6 +119,7 @@ export type ModelSelectorSelection =
119
119
  interface PendingThinkingChoice {
120
120
  item: ModelItem | CanonicalModelItem;
121
121
  role: SkcModelAssignmentTargetId | null;
122
+ roles?: readonly SkcModelAssignmentTargetId[];
122
123
  levels: ThinkingLevel[];
123
124
  }
124
125
 
@@ -1387,8 +1388,13 @@ export class ModelSelectorComponent extends Container {
1387
1388
  }
1388
1389
 
1389
1390
  #renderThinkingMenu(choice: PendingThinkingChoice): void {
1390
- const targetLabel =
1391
- choice.role === null ? t("modelSelector.temporaryModel") : SKC_MODEL_ASSIGNMENT_TARGETS[choice.role].name;
1391
+ const targetLabel = choice.roles
1392
+ ? choice.roles.includes("default")
1393
+ ? "all targets"
1394
+ : "all role agents"
1395
+ : choice.role === null
1396
+ ? t("modelSelector.temporaryModel")
1397
+ : SKC_MODEL_ASSIGNMENT_TARGETS[choice.role].name;
1392
1398
  this.#listContainer.addChild(new Spacer(1));
1393
1399
  this.#listContainer.addChild(
1394
1400
  new Text(
@@ -1699,14 +1705,16 @@ export class ModelSelectorComponent extends Container {
1699
1705
  const level = choice.levels[this.#selectedThinkingIndex];
1700
1706
  if (!level) return;
1701
1707
  this.#pendingThinkingChoice = undefined;
1702
- this.#handleSelect(choice.item, choice.role, level);
1708
+ this.#handleSelect(choice.item, choice.role, level, choice.roles);
1703
1709
  return;
1704
1710
  }
1705
1711
  if (getKeybindings().matches(keyData, "tui.select.cancel")) {
1706
1712
  this.#pendingThinkingChoice = undefined;
1707
1713
  if (choice.role !== null) {
1708
1714
  this.#pendingActionItem = choice.item;
1709
- this.#selectedActionIndex = Math.max(0, SKC_MODEL_ASSIGNMENT_TARGET_IDS.indexOf(choice.role));
1715
+ this.#selectedActionIndex = choice.roles
1716
+ ? SKC_MODEL_ASSIGNMENT_TARGET_IDS.length + (choice.roles.includes("default") ? 1 : 0)
1717
+ : Math.max(0, SKC_MODEL_ASSIGNMENT_TARGET_IDS.indexOf(choice.role));
1710
1718
  }
1711
1719
  this.#updateList();
1712
1720
  }
@@ -1735,9 +1743,12 @@ export class ModelSelectorComponent extends Container {
1735
1743
  ): void {
1736
1744
  const itemThinkingLevel = thinkingLevel ?? item.thinkingLevel;
1737
1745
  const hasExplicitThinkingChoice = thinkingLevel !== undefined || item.explicitThinkingLevel === true;
1738
- if (!hasExplicitThinkingChoice && requiresExplicitThinkingChoice(item.model, role)) {
1746
+ const needsExplicitThinkingChoice = roles
1747
+ ? roles.some(targetRole => requiresExplicitThinkingChoice(item.model, targetRole))
1748
+ : requiresExplicitThinkingChoice(item.model, role);
1749
+ if (!hasExplicitThinkingChoice && needsExplicitThinkingChoice) {
1739
1750
  const levels = getSelectableThinkingLevels(item.model);
1740
- this.#pendingThinkingChoice = { item, role, levels };
1751
+ this.#pendingThinkingChoice = { item, role, roles, levels };
1741
1752
  this.#selectedThinkingIndex = this.#getInitialThinkingChoiceIndex(item, levels);
1742
1753
  this.#updateList();
1743
1754
  return;
@@ -180,7 +180,7 @@ class SessionList implements Component {
180
180
 
181
181
  // Add keybinding hint
182
182
  lines.push("");
183
- lines.push(theme.fg("muted", " [Del to delete, Enter to select, Esc to cancel]"));
183
+ lines.push(theme.fg("muted", " [Del to delete selected transcript/artifacts, Enter to select, Esc to cancel]"));
184
184
 
185
185
  return lines;
186
186
  }
@@ -307,7 +307,7 @@ export class SessionSelectorComponent extends Container {
307
307
  #showDeleteConfirmation(session: SessionInfo): void {
308
308
  const displayName = session.title || session.firstMessage.slice(0, 40) || session.id;
309
309
  this.#confirmationDialog = new HookSelectorComponent(
310
- `Delete session?\n${displayName}`,
310
+ `Delete selected session transcript and artifacts?\n${displayName}\nThis cannot be undone. Other sessions and topic/history metadata are not deleted.`,
311
311
  ["Yes", "No"],
312
312
  async (option: string) => {
313
313
  if (option === "Yes" && this.#onDelete) {
@@ -3,19 +3,7 @@ import type { PresetDef, StatusLinePreset } from "./types";
3
3
  export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
4
4
  default: {
5
5
  leftSegments: ["model", "mode", "git", "pr", "path"],
6
- rightSegments: [
7
- "session_name",
8
- "jobs",
9
- "token_in",
10
- "token_out",
11
- "cache_read",
12
- "token_rate",
13
- "context_bar",
14
- "context_pct",
15
- "cost",
16
- "usage",
17
- "time_spent",
18
- ],
6
+ rightSegments: ["session_name", "jobs", "token_rate", "cost"],
19
7
  separator: "slash",
20
8
  segmentOptions: {
21
9
  model: { showThinkingLevel: true },
@@ -26,7 +14,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
26
14
 
27
15
  "default-usage": {
28
16
  leftSegments: ["model", "mode", "git", "pr", "path"],
29
- rightSegments: ["session_name", "jobs", "token_rate", "usage", "context_pct", "cost"],
17
+ rightSegments: ["session_name", "jobs", "token_rate", "usage", "cost"],
30
18
  separator: "slash",
31
19
  segmentOptions: {
32
20
  model: { showThinkingLevel: true },
@@ -47,7 +35,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
47
35
 
48
36
  compact: {
49
37
  leftSegments: ["model", "mode", "git", "pr"],
50
- rightSegments: ["session_name", "jobs", "cost", "context_pct"],
38
+ rightSegments: ["session_name", "jobs", "cost"],
51
39
  separator: "slash",
52
40
  segmentOptions: {
53
41
  model: { showThinkingLevel: false },
@@ -65,7 +53,6 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
65
53
  "token_rate",
66
54
  "cache_read",
67
55
  "cost",
68
- "context_pct",
69
56
  "time_spent",
70
57
  "time",
71
58
  ],
@@ -90,7 +77,6 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
90
77
  "cache_write",
91
78
  "token_rate",
92
79
  "cost",
93
- "context_pct",
94
80
  "context_total",
95
81
  "time_spent",
96
82
  "time",
@@ -107,7 +93,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
107
93
  ascii: {
108
94
  // No Nerd Font dependencies
109
95
  leftSegments: ["model", "mode", "path", "git", "pr"],
110
- rightSegments: ["session_name", "jobs", "token_total", "cost", "context_pct"],
96
+ rightSegments: ["session_name", "jobs", "token_total", "cost"],
111
97
  separator: "ascii",
112
98
  segmentOptions: {
113
99
  model: { showThinkingLevel: true },
@@ -119,7 +105,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
119
105
  custom: {
120
106
  // User-defined - these are just defaults that get overridden
121
107
  leftSegments: ["model", "mode", "path", "git", "pr"],
122
- rightSegments: ["session_name", "jobs", "token_total", "cost", "context_pct"],
108
+ rightSegments: ["session_name", "jobs", "token_total", "cost"],
123
109
  separator: "slash",
124
110
  segmentOptions: {},
125
111
  },
@@ -106,6 +106,20 @@ const modelSegment: StatusLineSegment = {
106
106
  }
107
107
  }
108
108
  }
109
+ // Show the context-window token percentage right next to the model /
110
+ // reasoning effort (rather than as a trailing segment) so it stays
111
+ // grouped with the model it describes. Disable per-preset with
112
+ // `segmentOptions.model.showContextPercent: false`. Suppressed
113
+ // automatically when a standalone context_pct segment is also in the
114
+ // active layout, so the value is never shown twice.
115
+ if (opts.showContextPercent !== false && ctx.contextPctSegmentActive !== true) {
116
+ const pct = ctx.contextPercent;
117
+ const window = ctx.contextWindow;
118
+ if (window > 0 && Number.isFinite(pct)) {
119
+ const color = getContextUsageThemeColor(getContextUsageLevel(pct, window));
120
+ content += `${theme.sep.dot}${theme.fg(color, `${pct.toFixed(1)}%`)}`;
121
+ }
122
+ }
109
123
 
110
124
  return { content: theme.fg("statusLineModel", content), visible: true };
111
125
  },
@@ -41,6 +41,12 @@ export interface SegmentContext {
41
41
  };
42
42
  contextPercent: number;
43
43
  contextWindow: number;
44
+ /**
45
+ * True when a standalone `context_pct` segment is also part of the active
46
+ * layout. The model segment suppresses its inline percentage in that case to
47
+ * avoid showing the same value twice.
48
+ */
49
+ contextPctSegmentActive?: boolean;
44
50
  autoCompactEnabled: boolean;
45
51
  subagentCount: number;
46
52
  jobs: JobsSnapshot;
@@ -29,7 +29,7 @@ import { calculateTokensPerSecond } from "./status-line/token-rate";
29
29
  import type { SeparatorDef } from "./status-line/types";
30
30
 
31
31
  export interface StatusLineSegmentOptions {
32
- model?: { showThinkingLevel?: boolean };
32
+ model?: { showThinkingLevel?: boolean; showContextPercent?: boolean };
33
33
  path?: { abbreviate?: boolean; maxLength?: number; stripWorkPrefix?: boolean };
34
34
  git?: { showBranch?: boolean; showStaged?: boolean; showUnstaged?: boolean; showUntracked?: boolean };
35
35
  time?: { format?: "12h" | "24h"; showSeconds?: boolean };
@@ -700,6 +700,11 @@ export class StatusLineComponent implements Component {
700
700
  const contextTokens = breakdown.usedTokens;
701
701
  const contextWindow = breakdown.contextWindow || state.model?.contextWindow || 0;
702
702
  const contextPercent = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
703
+ // Suppress the inline model percentage when a standalone context_pct
704
+ // segment is also rendered, so the value is not shown twice.
705
+ const contextPctSegmentActive =
706
+ effectiveSettings.leftSegments.includes("context_pct") ||
707
+ effectiveSettings.rightSegments.includes("context_pct");
703
708
 
704
709
  return {
705
710
  session: this.session,
@@ -710,6 +715,7 @@ export class StatusLineComponent implements Component {
710
715
  usageStats,
711
716
  contextPercent,
712
717
  contextWindow,
718
+ contextPctSegmentActive,
713
719
  autoCompactEnabled: this.#autoCompactEnabled,
714
720
  subagentCount: this.#subagentCount,
715
721
  jobs: this.#jobs,
@@ -923,11 +929,8 @@ export class StatusLineComponent implements Component {
923
929
  const totalWidth = () => leftWidth + rightWidth + (left.length > 0 && right.length > 0 ? 1 : 0);
924
930
 
925
931
  if (topFillWidth > 0) {
926
- while (totalWidth() > topFillWidth && right.length > 0) {
927
- right.pop();
928
- rightWidth = this.#groupWidth(right, rightCapWidth, rightSepWidth);
929
- }
930
- // Shrink path before dropping left segments — path is the only elastic segment
932
+ // Shrink path before dropping right-side telemetry — path is the only elastic segment,
933
+ // and presets such as default-usage should not hide usage just because cwd is long.
931
934
  const pathIdx = leftIds.indexOf("path");
932
935
  if (pathIdx >= 0 && totalWidth() > topFillWidth) {
933
936
  const overflow = totalWidth() - topFillWidth;
@@ -937,6 +940,10 @@ export class StatusLineComponent implements Component {
937
940
  leftWidth = this.#groupWidth(left, leftCapWidth, leftSepWidth);
938
941
  }
939
942
  }
943
+ while (totalWidth() > topFillWidth && right.length > 0) {
944
+ right.pop();
945
+ rightWidth = this.#groupWidth(right, rightCapWidth, rightSepWidth);
946
+ }
940
947
  while (totalWidth() > topFillWidth && left.length > 0) {
941
948
  left.pop();
942
949
  leftIds.pop();
@@ -678,7 +678,7 @@ export class ToolExecutionComponent extends Container {
678
678
  imageData,
679
679
  imageMimeType,
680
680
  { fallbackColor: (s: string) => theme.fg("toolOutput", s) },
681
- resolveImageOptions(),
681
+ { ...resolveImageOptions(), refetch: () => imageData },
682
682
  );
683
683
  this.#imageComponents.push(imageComponent);
684
684
  this.addChild(imageComponent);
@@ -1,5 +1,6 @@
1
1
  import { type Component, padding, TERMINAL, truncateToWidth, visibleWidth } from "@sayknow-cli/tui";
2
2
  import { APP_NAME } from "@sayknow-cli/utils";
3
+ import { formatBuildLabel } from "../../build-metadata";
3
4
  import { t } from "../../i18n";
4
5
  import { type ThemeColor, theme } from "../../modes/theme/theme";
5
6
 
@@ -20,6 +21,7 @@ export interface WelcomeComponentOptions {
20
21
  getReservedBottomRows?: (termWidth: number) => number;
21
22
  changelogMarkdown?: string;
22
23
  collapseChangelog?: boolean;
24
+ buildLabel?: string;
23
25
  }
24
26
 
25
27
  const WELCOME_STATIC_RIGHT_ROWS_EXCLUDING_DYNAMIC_SECTIONS = 15;
@@ -249,7 +251,8 @@ export class WelcomeComponent implements Component {
249
251
  const br = theme.fg("dim", theme.boxRound.bottomRight);
250
252
 
251
253
  const lines: string[] = [];
252
- const title = ` ${APP_NAME} · Sayknow-CLI v${this.version} `;
254
+ const buildLabel = this.options.buildLabel ?? formatBuildLabel();
255
+ const title = ` ${APP_NAME} · Sayknow-CLI v${this.version} · ${buildLabel} `;
253
256
  const titlePrefixRaw = hChar.repeat(3);
254
257
  const titleStyled = theme.fg("dim", titlePrefixRaw) + theme.fg("muted", title);
255
258
  const titleVisLen = visibleWidth(titlePrefixRaw) + visibleWidth(title);
@@ -567,7 +567,7 @@ export class CommandController {
567
567
  `| Start a fresh session | \`${sessionNewKey}\` or \`/new\` |`,
568
568
  "| Resume another session | `/resume` |",
569
569
  "| Show session details | `/session info` |",
570
- "| Delete current session | `/session delete` |",
570
+ "| Delete current session transcript/artifacts | `/session delete` |",
571
571
  "| Select a model | `/model` or `Ctrl+L` |",
572
572
  "| Show all shortcuts | `?` on an empty prompt or `/hotkeys` |",
573
573
  "",
@@ -26,7 +26,7 @@ import { interruptHint } from "../shared";
26
26
  import { buildAbortDisplayMessage } from "../utils/abort-message";
27
27
  import { consumeInjectedOptimisticSignature } from "../utils/injected-user-submission";
28
28
  import { ringTerminalBell } from "../utils/terminal-bell";
29
- import { argsWithPartialJson } from "../utils/ui-helpers";
29
+ import { addChatChild, argsWithPartialJson } from "../utils/ui-helpers";
30
30
 
31
31
  type AgentSessionEventKind = AgentSessionEvent["type"];
32
32
 
@@ -169,12 +169,12 @@ export class EventController {
169
169
 
170
170
  #getReadGroup(): ReadToolGroupComponent {
171
171
  if (!this.#lastReadGroup) {
172
- this.ctx.chatContainer.addChild(new Text("", 0, 0));
172
+ addChatChild(this.ctx, new Text("", 0, 0));
173
173
  const group = new ReadToolGroupComponent({
174
174
  showContentPreview: this.ctx.settings.get("read.toolResultPreview"),
175
175
  });
176
176
  group.setExpanded(this.ctx.toolOutputExpanded);
177
- this.ctx.chatContainer.addChild(group);
177
+ addChatChild(this.ctx, group);
178
178
  this.#lastReadGroup = group;
179
179
  }
180
180
  return this.#lastReadGroup;
@@ -332,7 +332,7 @@ export class EventController {
332
332
  this.ctx.ui.requestRender(),
333
333
  );
334
334
  this.ctx.streamingMessage = event.message;
335
- this.ctx.chatContainer.addChild(this.ctx.streamingComponent);
335
+ addChatChild(this.ctx, this.ctx.streamingComponent);
336
336
  this.ctx.streamingComponent.updateContent(this.ctx.streamingMessage, { streaming: true });
337
337
  this.ctx.ui.requestRender();
338
338
  }
@@ -445,7 +445,7 @@ export class EventController {
445
445
  );
446
446
  if (!this.ctx.pendingTools.has(content.id)) {
447
447
  this.#resetReadGroup();
448
- this.ctx.chatContainer.addChild(new Text("", 0, 0));
448
+ addChatChild(this.ctx, new Text("", 0, 0));
449
449
  const tool = this.ctx.session.getToolByName(content.name);
450
450
  const component = new ToolExecutionComponent(
451
451
  content.name,
@@ -462,8 +462,8 @@ export class EventController {
462
462
  content.id,
463
463
  );
464
464
  component.setExpanded(this.ctx.toolOutputExpanded);
465
- this.ctx.chatContainer.addChild(component);
466
465
  this.ctx.pendingTools.set(content.id, component);
466
+ addChatChild(this.ctx, component);
467
467
  } else {
468
468
  this.ctx.pendingTools.get(content.id)?.updateArgs(renderArgs, content.id);
469
469
  }
@@ -575,8 +575,8 @@ export class EventController {
575
575
  event.toolCallId,
576
576
  );
577
577
  component.setExpanded(this.ctx.toolOutputExpanded);
578
- this.ctx.chatContainer.addChild(component);
579
578
  this.ctx.pendingTools.set(event.toolCallId, component);
579
+ addChatChild(this.ctx, component);
580
580
  this.ctx.ui.requestRender();
581
581
  }
582
582
  }
@@ -869,13 +869,13 @@ export class EventController {
869
869
  async #handleTtsrTriggered(event: Extract<AgentSessionEvent, { type: "ttsr_triggered" }>): Promise<void> {
870
870
  const component = new TtsrNotificationComponent(event.rules);
871
871
  component.setExpanded(this.ctx.toolOutputExpanded);
872
- this.ctx.chatContainer.addChild(component);
872
+ addChatChild(this.ctx, component);
873
873
  this.ctx.ui.requestRender();
874
874
  }
875
875
 
876
876
  async #handleTodoReminder(event: Extract<AgentSessionEvent, { type: "todo_reminder" }>): Promise<void> {
877
877
  const component = new TodoReminderComponent(event.todos, event.attempt, event.maxAttempts);
878
- this.ctx.chatContainer.addChild(component);
878
+ addChatChild(this.ctx, component);
879
879
  this.ctx.ui.requestRender();
880
880
  }
881
881
 
@@ -18,7 +18,7 @@ import { copyToClipboard, readImageFromClipboard } from "../../utils/clipboard";
18
18
  import { getEditorCommand, openInEditor } from "../../utils/external-editor";
19
19
  import { ensureSupportedImageInput, ImageInputTooLargeError, loadImageInput } from "../../utils/image-loading";
20
20
  import { resizeImage } from "../../utils/image-resize";
21
- import { resolvePastedImagePath } from "../../utils/pasted-image-path";
21
+ import { formatPastedImageReference, resolvePastedImagePath } from "../../utils/pasted-image-path";
22
22
  import { generateSessionTitle, setSessionTerminalTitle } from "../../utils/title-generator";
23
23
  import { type QueuedMessageMoveDirection, QueuedMessageSelectorComponent } from "../components/queued-message-selector";
24
24
 
@@ -149,6 +149,12 @@ export class InputController {
149
149
  return { consume: true };
150
150
  }
151
151
  const hookDialogActive = this.#hasHookDialog();
152
+ if (this.ctx.hookSelector?.hasActiveInlineInput?.() === true) {
153
+ // Inline ask/custom-input editors use Esc to return to the option list.
154
+ // Let the focused selector see the key instead of converting a typo
155
+ // into a full workflow/session abort while the agent is streaming.
156
+ return undefined;
157
+ }
152
158
  if (
153
159
  this.#handleCancellableWorkEscape({
154
160
  loading: hookDialogActive,
@@ -1096,7 +1102,7 @@ export class InputController {
1096
1102
  data: image.data,
1097
1103
  mimeType: image.mimeType,
1098
1104
  });
1099
- this.ctx.editor.insertText(`${this.#nextImagePlaceholder()} `);
1105
+ this.ctx.editor.insertText(`${formatPastedImageReference(this.#nextImagePlaceholder(), image.resolvedPath)} `);
1100
1106
  this.ctx.showStatus(`Attached image: ${path.basename(image.resolvedPath)}`, { dim: true });
1101
1107
  this.ctx.ui.requestRender();
1102
1108
  return true;
@@ -1333,8 +1333,12 @@ export class SelectorController {
1333
1333
  }
1334
1334
 
1335
1335
  const confirmed = await this.ctx.showHookConfirm(
1336
- "Delete Session",
1337
- "This will permanently delete the current session.\nYou will be returned to the session selector.",
1336
+ "Delete current session transcript and artifacts?",
1337
+ [
1338
+ "This permanently deletes only the current session transcript file and its artifacts directory.",
1339
+ "Other sessions and topic/history metadata are not deleted.",
1340
+ "You will be moved to a fresh session and returned to the session selector.",
1341
+ ].join("\n"),
1338
1342
  );
1339
1343
 
1340
1344
  if (!confirmed) {
@@ -1351,7 +1355,7 @@ export class SelectorController {
1351
1355
  await storage.deleteSessionWithArtifacts(sessionFile);
1352
1356
 
1353
1357
  // Show session selector
1354
- this.ctx.showStatus("Session deleted");
1358
+ this.ctx.showStatus("Current session transcript and artifacts deleted");
1355
1359
  await this.showSessionSelector();
1356
1360
  }
1357
1361
 
@@ -18,6 +18,7 @@ import type { Component, EditorTheme, SlashCommand } from "@sayknow-cli/tui";
18
18
  import {
19
19
  Container,
20
20
  clearRenderCache,
21
+ getRenderCacheRetainedBytes,
21
22
  Loader,
22
23
  Markdown,
23
24
  ProcessTerminal,
@@ -115,7 +116,7 @@ import {
115
116
  theme,
116
117
  } from "./theme/theme";
117
118
  import type { CompactionQueuedMessage, InteractiveModeContext, SubmittedUserInput, TodoItem, TodoPhase } from "./types";
118
- import { UiHelpers } from "./utils/ui-helpers";
119
+ import { addChatChild, UiHelpers } from "./utils/ui-helpers";
119
120
 
120
121
  const INTERACTIVE_ABORT_CLEANUP_TIMEOUT_MS = 5_000;
121
122
  const COMPOSER_NEWLINE_HINT = process.platform === "win32" ? "Alt+Enter/Ctrl+J" : "Shift+Enter/Ctrl+J";
@@ -399,6 +400,10 @@ export class InteractiveMode implements InteractiveModeContext {
399
400
  ) {
400
401
  this.session = session;
401
402
  this.sessionManager = session.sessionManager;
403
+ this.session.setRetainedMemorySampler(() => ({
404
+ tuiChatChildren: this.chatContainer.children.length,
405
+ tuiCachedRenderBytes: getRenderCacheRetainedBytes(),
406
+ }));
402
407
  this.settings = session.settings;
403
408
  this.keybindings = KeybindingsManager.inMemory();
404
409
  this.agent = session.agent;
@@ -1515,8 +1520,11 @@ export class InteractiveMode implements InteractiveModeContext {
1515
1520
 
1516
1521
  #renderPlanPreview(planContent: string, options?: { append?: boolean }): void {
1517
1522
  const existingContainer = this.#planReviewContainer;
1518
- const replaceExisting = options?.append !== true && existingContainer !== undefined;
1519
- const planReviewContainer = replaceExisting ? existingContainer : new Container();
1523
+ // Only reuse the existing preview when it is still attached to the chat container;
1524
+ // a collapsed/disposed container must be re-added, not mutated in place.
1525
+ const stillAttached = existingContainer !== undefined && this.chatContainer.children.includes(existingContainer);
1526
+ const replaceExisting = options?.append !== true && stillAttached;
1527
+ const planReviewContainer = replaceExisting && existingContainer ? existingContainer : new Container();
1520
1528
  planReviewContainer.clear();
1521
1529
  planReviewContainer.addChild(new Spacer(1));
1522
1530
  planReviewContainer.addChild(new DynamicBorder());
@@ -1525,7 +1533,7 @@ export class InteractiveMode implements InteractiveModeContext {
1525
1533
  planReviewContainer.addChild(new Markdown(planContent, 1, 1, getMarkdownTheme()));
1526
1534
  planReviewContainer.addChild(new DynamicBorder());
1527
1535
  if (!replaceExisting) {
1528
- this.chatContainer.addChild(planReviewContainer);
1536
+ addChatChild(this, planReviewContainer);
1529
1537
  }
1530
1538
  this.#planReviewContainer = planReviewContainer;
1531
1539
  this.ui.requestRender();