@oh-my-pi/pi-coding-agent 15.10.2 → 15.10.4

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 (95) hide show
  1. package/CHANGELOG.md +66 -1
  2. package/dist/types/cli/gallery-fixtures/types.d.ts +7 -1
  3. package/dist/types/edit/index.d.ts +0 -1
  4. package/dist/types/eval/__tests__/js-context-manager.test.d.ts +1 -0
  5. package/dist/types/eval/bridge-timeout.d.ts +1 -1
  6. package/dist/types/eval/{llm-bridge.d.ts → completion-bridge.d.ts} +8 -8
  7. package/dist/types/eval/idle-timeout.d.ts +1 -1
  8. package/dist/types/lsp/index.d.ts +0 -5
  9. package/dist/types/main.d.ts +11 -0
  10. package/dist/types/modes/components/assistant-message.d.ts +0 -9
  11. package/dist/types/modes/components/late-diagnostics-message.d.ts +20 -0
  12. package/dist/types/modes/components/read-tool-group.d.ts +6 -0
  13. package/dist/types/modes/components/session-selector.d.ts +16 -7
  14. package/dist/types/modes/components/tool-execution.d.ts +0 -18
  15. package/dist/types/modes/types.d.ts +4 -0
  16. package/dist/types/session/messages.d.ts +11 -8
  17. package/dist/types/session/yield-queue.d.ts +10 -1
  18. package/dist/types/tools/eval-render.d.ts +0 -1
  19. package/dist/types/tools/index.d.ts +31 -0
  20. package/dist/types/tools/path-utils.d.ts +5 -1
  21. package/dist/types/tools/read.d.ts +2 -1
  22. package/dist/types/tools/render-utils.d.ts +3 -1
  23. package/dist/types/tools/renderers.d.ts +0 -15
  24. package/dist/types/tools/write.d.ts +0 -2
  25. package/dist/types/tui/code-cell.d.ts +0 -2
  26. package/dist/types/tui/hyperlink.d.ts +5 -7
  27. package/dist/types/tui/output-block.d.ts +0 -18
  28. package/package.json +9 -9
  29. package/src/cli/gallery-cli.ts +4 -0
  30. package/src/cli/gallery-fixtures/codeintel.ts +0 -1
  31. package/src/cli/gallery-fixtures/fs.ts +68 -1
  32. package/src/cli/gallery-fixtures/types.ts +8 -1
  33. package/src/commit/agentic/agent.ts +1 -0
  34. package/src/edit/hashline/diff.ts +86 -0
  35. package/src/edit/hashline/execute.ts +14 -1
  36. package/src/edit/index.ts +31 -17
  37. package/src/edit/renderer.ts +116 -31
  38. package/src/eval/__tests__/agent-bridge.test.ts +13 -0
  39. package/src/eval/__tests__/{llm-bridge.test.ts → completion-bridge.test.ts} +60 -54
  40. package/src/eval/__tests__/js-context-manager.test.ts +241 -0
  41. package/src/eval/agent-bridge.ts +6 -1
  42. package/src/eval/bridge-timeout.ts +1 -1
  43. package/src/eval/{llm-bridge.ts → completion-bridge.ts} +30 -27
  44. package/src/eval/idle-timeout.ts +1 -1
  45. package/src/eval/js/context-manager.ts +66 -6
  46. package/src/eval/js/shared/prelude.txt +28 -12
  47. package/src/eval/js/tool-bridge.ts +3 -3
  48. package/src/eval/js/worker-entry.ts +6 -0
  49. package/src/eval/py/prelude.py +3 -3
  50. package/src/internal-urls/docs-index.generated.ts +8 -7
  51. package/src/lsp/index.ts +128 -52
  52. package/src/main.ts +54 -14
  53. package/src/modes/components/assistant-message.ts +3 -15
  54. package/src/modes/components/late-diagnostics-message.ts +60 -0
  55. package/src/modes/components/plan-review-overlay.ts +26 -5
  56. package/src/modes/components/read-tool-group.ts +415 -35
  57. package/src/modes/components/session-selector.ts +89 -35
  58. package/src/modes/components/tips.txt +1 -1
  59. package/src/modes/components/tool-execution.ts +7 -49
  60. package/src/modes/components/transcript-container.ts +108 -32
  61. package/src/modes/controllers/event-controller.ts +6 -1
  62. package/src/modes/controllers/input-controller.ts +10 -2
  63. package/src/modes/types.ts +4 -0
  64. package/src/modes/utils/ui-helpers.ts +26 -5
  65. package/src/prompts/system/manual-continue.md +7 -0
  66. package/src/prompts/system/plan-mode-active.md +56 -72
  67. package/src/prompts/system/tiny-title-system.md +1 -1
  68. package/src/prompts/system/title-system.md +16 -3
  69. package/src/prompts/system/workflow-notice.md +1 -1
  70. package/src/prompts/tools/eval.md +6 -4
  71. package/src/prompts/tools/lsp-late-diagnostic.md +8 -0
  72. package/src/sdk.ts +59 -1
  73. package/src/session/agent-session.ts +5 -3
  74. package/src/session/messages.ts +21 -14
  75. package/src/session/session-manager.ts +2 -2
  76. package/src/session/yield-queue.ts +20 -2
  77. package/src/task/executor.ts +1 -0
  78. package/src/tiny/title-client.ts +6 -1
  79. package/src/tools/bash.ts +0 -7
  80. package/src/tools/eval-render.ts +6 -25
  81. package/src/tools/eval.ts +1 -1
  82. package/src/tools/find.ts +148 -106
  83. package/src/tools/index.ts +32 -0
  84. package/src/tools/path-utils.ts +19 -22
  85. package/src/tools/read.ts +16 -8
  86. package/src/tools/render-utils.ts +3 -1
  87. package/src/tools/renderers.ts +0 -15
  88. package/src/tools/ssh.ts +0 -1
  89. package/src/tools/todo.ts +1 -0
  90. package/src/tools/write.ts +3 -12
  91. package/src/tui/code-cell.ts +1 -6
  92. package/src/tui/hyperlink.ts +13 -23
  93. package/src/tui/output-block.ts +2 -97
  94. package/src/utils/title-generator.ts +2 -2
  95. /package/dist/types/eval/__tests__/{llm-bridge.test.d.ts → completion-bridge.test.d.ts} +0 -0
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  type Component,
3
3
  Container,
4
- fuzzyFilter,
4
+ fuzzyMatch,
5
5
  Input,
6
6
  matchesKey,
7
7
  padding,
@@ -46,43 +46,107 @@ function formatSessionStatus(status: SessionStatus | undefined): string | undefi
46
46
  /** Returns the IDs of sessions whose recorded prompts match a query, best first. */
47
47
  export type SessionHistoryMatcher = (query: string) => string[];
48
48
 
49
+ function sessionSearchText(session: SessionInfo): string {
50
+ const parts = [
51
+ session.id,
52
+ session.title ?? "",
53
+ session.cwd ?? "",
54
+ session.firstMessage ?? "",
55
+ session.allMessagesText,
56
+ session.path,
57
+ ];
58
+ return parts.filter(Boolean).join(" ");
59
+ }
60
+
61
+ function tokenizeSessionQuery(query: string): string[] {
62
+ const trimmed = query.trim().toLowerCase();
63
+ return trimmed ? trimmed.split(/\s+/) : [];
64
+ }
65
+
66
+ function compareSessionRecency(a: SessionInfo, b: SessionInfo): number {
67
+ return b.modified.getTime() - a.modified.getTime();
68
+ }
69
+
70
+ /**
71
+ * Filter and rank session picker search results.
72
+ *
73
+ * Resume search narrows a recency-sorted list: once every query token appears
74
+ * as a literal substring, newer sessions should beat a slightly better fuzzy
75
+ * position match. Pure fuzzy/acronym matches still sort by fuzzy score after
76
+ * literal matches.
77
+ */
78
+ export function rankSessionSearchMatches(allSessions: SessionInfo[], query: string): SessionInfo[] {
79
+ const tokens = tokenizeSessionQuery(query);
80
+ if (tokens.length === 0) return allSessions;
81
+
82
+ const results: Array<{ session: SessionInfo; score: number; literal: boolean; index: number }> = [];
83
+ for (let index = 0; index < allSessions.length; index++) {
84
+ const session = allSessions[index]!;
85
+ const text = sessionSearchText(session);
86
+ const textLower = text.toLowerCase();
87
+ let score = 0;
88
+ let literal = true;
89
+ let matches = true;
90
+
91
+ for (const token of tokens) {
92
+ const match = fuzzyMatch(token, textLower);
93
+ if (!match.matches) {
94
+ matches = false;
95
+ break;
96
+ }
97
+ score += match.score;
98
+ if (!textLower.includes(token)) literal = false;
99
+ }
100
+
101
+ if (matches) results.push({ session, score, literal, index });
102
+ }
103
+
104
+ results.sort((a, b) => {
105
+ if (a.literal !== b.literal) return a.literal ? -1 : 1;
106
+ if (a.literal) return compareSessionRecency(a.session, b.session) || a.index - b.index;
107
+ return a.score - b.score || compareSessionRecency(a.session, b.session) || a.index - b.index;
108
+ });
109
+
110
+ return results.map(result => result.session);
111
+ }
112
+
49
113
  /**
50
- * Combine fuzzy session matches with prompt-history matches for ranking, using
51
- * both signals rather than replacing one with the other.
114
+ * Combine metadata matches with prompt-history matches for ranking, using both
115
+ * signals rather than replacing one with the other.
52
116
  *
53
- * - `fuzzy` is the ordered fuzzy-filter result over session metadata (best first).
117
+ * - `fuzzy` is the ordered metadata/session-text result.
54
118
  * - `historyIds` are session IDs whose recorded prompts matched the query,
55
119
  * ordered by prompt-history rank (typically newest matching prompt first); duplicates are tolerated.
56
120
  *
57
- * Ranking: sessions matched by **both** signals lead (keeping fuzzy order), then
58
- * fuzzy-only matches, then history-only matches (by prompt-history order). A fuzzy match
59
- * is never dropped, and history matches not present in `allSessions` (e.g. deleted
60
- * or out-of-scope sessions) are ignored since they cannot be resumed from here.
121
+ * Ranking: prompt-history matches lead in history order, then remaining
122
+ * metadata matches keep their existing order. A metadata match is never dropped,
123
+ * and history matches not present in `allSessions` (e.g. deleted or out-of-scope
124
+ * sessions) are ignored since they cannot be resumed from here.
61
125
  */
62
126
  export function mergeSessionRanking(
63
127
  allSessions: SessionInfo[],
64
128
  fuzzy: SessionInfo[],
65
129
  historyIds: string[],
66
130
  ): SessionInfo[] {
67
- const historyRank = new Map<string, number>();
68
- historyIds.forEach((id, index) => {
69
- if (!historyRank.has(id)) historyRank.set(id, index);
70
- });
71
- if (historyRank.size === 0) return fuzzy;
72
-
73
- const both: SessionInfo[] = [];
74
- const fuzzyOnly: SessionInfo[] = [];
75
- const fuzzyPaths = new Set<string>();
76
- for (const session of fuzzy) {
77
- fuzzyPaths.add(session.path);
78
- (historyRank.has(session.id) ? both : fuzzyOnly).push(session);
131
+ if (historyIds.length === 0) return fuzzy;
132
+
133
+ const sessionsById = new Map<string, SessionInfo>();
134
+ for (const session of allSessions) {
135
+ if (!sessionsById.has(session.id)) sessionsById.set(session.id, session);
79
136
  }
80
137
 
81
- const historyOnly = allSessions
82
- .filter(session => historyRank.has(session.id) && !fuzzyPaths.has(session.path))
83
- .sort((a, b) => (historyRank.get(a.id) ?? 0) - (historyRank.get(b.id) ?? 0));
138
+ const historyMatches: SessionInfo[] = [];
139
+ const historyPaths = new Set<string>();
140
+ for (const id of historyIds) {
141
+ const session = sessionsById.get(id);
142
+ if (!session || historyPaths.has(session.path)) continue;
143
+ historyMatches.push(session);
144
+ historyPaths.add(session.path);
145
+ }
146
+ if (historyMatches.length === 0) return fuzzy;
84
147
 
85
- return [...both, ...fuzzyOnly, ...historyOnly];
148
+ const metadataOnly = fuzzy.filter(session => !historyPaths.has(session.path));
149
+ return [...historyMatches, ...metadataOnly];
86
150
  }
87
151
 
88
152
  /**
@@ -156,17 +220,7 @@ class SessionList implements Component {
156
220
  }
157
221
 
158
222
  #filterSessions(query: string): void {
159
- const fuzzy = fuzzyFilter(this.#allSessions, query, session => {
160
- const parts = [
161
- session.id,
162
- session.title ?? "",
163
- session.cwd ?? "",
164
- session.firstMessage ?? "",
165
- session.allMessagesText,
166
- session.path,
167
- ];
168
- return parts.filter(Boolean).join(" ");
169
- });
223
+ const fuzzy = rankSessionSearchMatches(this.#allSessions, query);
170
224
  this.#filteredSessions = this.#mergeHistoryMatches(query, fuzzy);
171
225
  this.#selectedIndex = Math.min(this.#selectedIndex, Math.max(0, this.#filteredSessions.length - 1));
172
226
  }
@@ -4,7 +4,7 @@ Use /tan to fork the current conversation into a background agent
4
4
  Ctrl+D can be used to exit, but with your draft saved!
5
5
  Find out which model you emotionally abuse the most with `omp stats`
6
6
  Try task isolation to create CoW worktrees
7
- Your LLM can call an LLM using `llm(x...)`. Have a big batch of tasks? Ask clanker to use it!
7
+ Need a cheap nested model call? Use `completion(x...)`. Have a big batch of tasks? Ask clanker to use it!
8
8
  Spaghetti code? Try complaining with /omfg
9
9
  Did you know? Each kitty/tmux/cmux split keeps its own session — `omp -c` resumes the right one
10
10
  Drop the word `ultrathink` in your message for harder multi-step reasoning — watch it glow rainbow as you type
@@ -15,7 +15,6 @@ import {
15
15
  } from "@oh-my-pi/pi-tui";
16
16
  import { getProjectDir, logger, sanitizeText } from "@oh-my-pi/pi-utils";
17
17
  import { EDIT_MODE_STRATEGIES, type EditMode, type PerFileDiffPreview } from "../../edit";
18
- import { shimmerEnabled } from "../../modes/theme/shimmer";
19
18
  import type { Theme } from "../../modes/theme/theme";
20
19
  import { theme } from "../../modes/theme/theme";
21
20
  import { BASH_DEFAULT_PREVIEW_LINES } from "../../tools/bash";
@@ -31,7 +30,7 @@ import {
31
30
  renderJsonTreeLines,
32
31
  } from "../../tools/json-tree";
33
32
  import { formatExpandHint, replaceTabs, resolveImageOptions, truncateToWidth } from "../../tools/render-utils";
34
- import { type ToolRenderer, toolRenderers } from "../../tools/renderers";
33
+ import { toolRenderers } from "../../tools/renderers";
35
34
  import { TODO_STRIKE_TOTAL_FRAMES } from "../../tools/todo";
36
35
  import { isFramedBlockComponent, renderStatusLine } from "../../tui";
37
36
  import { sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
@@ -133,9 +132,10 @@ export interface ToolExecutionHandle {
133
132
  setExpanded(expanded: boolean): void;
134
133
  }
135
134
 
136
- /** Drive pending-tool redraws at 30fps so the animated border sweep stays
137
- * smooth without spending twice the frame budget. The TUI throttles at the same
138
- * cadence, and static frames diff to a no-op redraw at ~zero cost. */
135
+ /** Drive pending-tool redraws at 30fps so the running `task` row's shimmered
136
+ * subagent name stays smooth without spending twice the frame budget. The TUI
137
+ * throttles at the same cadence, and static frames diff to a no-op redraw at
138
+ * ~zero cost. */
139
139
  const SPINNER_RENDER_INTERVAL_MS = 1000 / 30;
140
140
  /** Advance the spinner glyph at its classic ~12.5fps step, decoupled from the
141
141
  * render cadence (mirrors `Loader`). */
@@ -425,16 +425,7 @@ export class ToolExecutionComponent extends Container {
425
425
  (this.#result?.details as { async?: { state?: string } } | undefined)?.async?.state === "running";
426
426
  const isBackgroundAsyncTask = this.#toolName === "task" && isBackgroundAsyncRunning;
427
427
  const isPartialTask = this.#isPartial && this.#toolName === "task" && !isBackgroundAsyncTask;
428
- // Sweep the border of bash/eval execution blocks while they're pending — but
429
- // not once they've been backgrounded: a backgrounded job's block gets
430
- // committed to scrollback and finalizes later via the async update path, so a
431
- // mid-sweep frame would freeze a stray dark "bar" segment into the border.
432
- const isPendingExecBlock =
433
- this.#isPartial &&
434
- shimmerEnabled() &&
435
- (this.#toolName === "bash" || this.#toolName === "eval") &&
436
- !isBackgroundAsyncRunning;
437
- const needsSpinner = isStreamingArgs || isPartialTask || isPendingExecBlock;
428
+ const needsSpinner = isStreamingArgs || isPartialTask;
438
429
  if (needsSpinner && !this.#spinnerInterval) {
439
430
  const now = performance.now();
440
431
  const frameCount = theme.spinnerFrames.length;
@@ -446,7 +437,7 @@ export class ToolExecutionComponent extends Container {
446
437
  this.#spinnerInterval = setInterval(() => {
447
438
  const now = performance.now();
448
439
  const frameCount = theme.spinnerFrames.length;
449
- // Redraw at 30fps for a smooth border sweep, but keep the spinner
440
+ // Redraw at 30fps for a smooth `task` name shimmer, but keep the spinner
450
441
  // glyph phase-locked to its classic ~12.5fps cadence. Advancing the
451
442
  // anchor by elapsed frames instead of resetting to `now` avoids the
452
443
  // 30fps timer quantizing the glyph down to one step every three ticks.
@@ -529,39 +520,6 @@ export class ToolExecutionComponent extends Container {
529
520
  return (this.#result.details as { async?: { state?: string } } | undefined)?.async?.state === "running";
530
521
  }
531
522
 
532
- /**
533
- * While a tool's preview is still streaming, a block whose preview is
534
- * append-only (rows only grow at the bottom, never re-layout) lets the
535
- * renderer commit the scrolled-off head of an over-tall preview to native
536
- * scrollback instead of dropping it — the same anti-yank path a streaming
537
- * assistant reply uses (see {@link TranscriptContainer} +
538
- * `NativeScrollbackLiveRegion`). Covers both phases: a pre-result call preview
539
- * (a `write` whose content streams in) and a partial-result preview that
540
- * streams output below fixed input (an `eval`/`bash` whose stdout grows under
541
- * its code cell). Gated on {@link isTranscriptBlockFinalized} so the boundary
542
- * closes the instant the block reaches a terminal state — a final result that
543
- * may collapse to a compact view, a backgrounded async tool, or a seal — and
544
- * the renderer decides whether its current preview shape qualifies via
545
- * `isStreamingPreviewAppendOnly` (typically: only the expanded full view,
546
- * which is top-anchored; the collapsed tail window re-layouts but is bounded
547
- * so it never overflows anyway).
548
- */
549
- isTranscriptBlockAppendOnly(): boolean {
550
- // A finalized block's preview can collapse/re-layout; only a live,
551
- // still-streaming block is a candidate.
552
- if (this.isTranscriptBlockFinalized()) return false;
553
- const predicate =
554
- (this.#tool as { isStreamingPreviewAppendOnly?: ToolRenderer["isStreamingPreviewAppendOnly"] } | undefined)
555
- ?.isStreamingPreviewAppendOnly ?? toolRenderers[this.#toolName]?.isStreamingPreviewAppendOnly;
556
- if (!predicate) return false;
557
- try {
558
- return predicate(this.#getCallArgsForRender(), this.#renderState, this.#result);
559
- } catch (err) {
560
- logger.warn("Tool append-only predicate failed", { tool: this.#toolName, error: String(err) });
561
- return false;
562
- }
563
- }
564
-
565
523
  /**
566
524
  * Mark the tool terminal even though no result arrived (the turn aborted or
567
525
  * abandoned it) and stop animating, so it can freeze and stops pinning the
@@ -6,6 +6,8 @@ interface FrozenRender {
6
6
  width: number;
7
7
  lines: string[];
8
8
  generation: number;
9
+ appendOnly: boolean;
10
+ volatile: boolean;
9
11
  }
10
12
 
11
13
  interface SnapshotCarrier {
@@ -17,16 +19,9 @@ interface SnapshotCarrier {
17
19
  * result, an assistant message mid-stream) reports `false` so the container
18
20
  * keeps it inside the live (repaintable) region instead of freezing it. Blocks
19
21
  * without the method are treated as finalized — the default, stable behavior.
20
- *
21
- * `isTranscriptBlockAppendOnly` marks a still-live block whose rendered rows
22
- * only grow at the bottom and never re-layout (a streaming assistant reply).
23
- * Such a block's scrolled-off head is safe to commit to native scrollback even
24
- * while live; blocks that omit it (tool previews that collapse to a compact
25
- * result) keep their mutable rows deferred. Default is `false`.
26
22
  */
27
23
  interface FinalizableBlock {
28
24
  isTranscriptBlockFinalized?(): boolean;
29
- isTranscriptBlockAppendOnly?(): boolean;
30
25
  }
31
26
 
32
27
  function isBlockFinalized(child: Component): boolean {
@@ -34,11 +29,6 @@ function isBlockFinalized(child: Component): boolean {
34
29
  return fn ? fn.call(child) : true;
35
30
  }
36
31
 
37
- function isBlockAppendOnly(child: Component): boolean {
38
- const fn = (child as Component & FinalizableBlock).isTranscriptBlockAppendOnly;
39
- return fn ? fn.call(child) : false;
40
- }
41
-
42
32
  // A "plain blank" row is empty or whitespace-only with no ANSI bytes. It marks
43
33
  // separation padding (a `Spacer`, or a no-background `paddingY` row) as opposed
44
34
  // to a background-colored padding row, whose escape sequences contain `\S` and
@@ -59,6 +49,73 @@ function stripPlainBlankEdges(lines: string[]): string[] {
59
49
  return start === 0 && end === lines.length ? lines : lines.slice(start, end);
60
50
  }
61
51
 
52
+ interface LiveCommitState {
53
+ appendOnly: boolean;
54
+ volatile: boolean;
55
+ safeLength: number;
56
+ }
57
+
58
+ function hasValidSnapshot(
59
+ snapshot: FrozenRender | undefined,
60
+ width: number,
61
+ generation: number,
62
+ ): snapshot is FrozenRender {
63
+ return snapshot !== undefined && snapshot.generation === generation && snapshot.width === width;
64
+ }
65
+
66
+ function commonPrefixLength(prev: string[], cur: string[]): number {
67
+ const limit = Math.min(prev.length, cur.length);
68
+ let i = 0;
69
+ while (i < limit && prev[i] === cur[i]) i++;
70
+ return i;
71
+ }
72
+
73
+ function commonSuffixLength(prev: string[], cur: string[], prefixLength: number): number {
74
+ const prevLimit = prev.length - prefixLength;
75
+ const curLimit = cur.length - prefixLength;
76
+ const limit = Math.min(prevLimit, curLimit);
77
+ let i = 0;
78
+ while (i < limit && prev[prev.length - 1 - i] === cur[cur.length - 1 - i]) i++;
79
+ return i;
80
+ }
81
+
82
+ function deriveLiveCommitState(
83
+ previous: FrozenRender | undefined,
84
+ current: string[],
85
+ width: number,
86
+ generation: number,
87
+ ): LiveCommitState {
88
+ let appendOnly = false;
89
+ let volatile = false;
90
+ if (hasValidSnapshot(previous, width, generation)) {
91
+ appendOnly = previous.appendOnly;
92
+ volatile = previous.volatile;
93
+
94
+ const prefixLength = commonPrefixLength(previous.lines, current);
95
+ const staticRender = prefixLength === previous.lines.length && prefixLength === current.length;
96
+ if (!staticRender) {
97
+ const suffixLength = commonSuffixLength(previous.lines, current, prefixLength);
98
+ const stablePreviousLength = prefixLength + suffixLength;
99
+ const appendGrew =
100
+ previous.lines.length > 0 &&
101
+ current.length > previous.lines.length &&
102
+ stablePreviousLength >= previous.lines.length;
103
+ if (appendGrew && !volatile) {
104
+ appendOnly = true;
105
+ } else if (stablePreviousLength < previous.lines.length) {
106
+ volatile = true;
107
+ appendOnly = false;
108
+ }
109
+ }
110
+ }
111
+
112
+ return {
113
+ appendOnly,
114
+ volatile,
115
+ safeLength: volatile ? 0 : appendOnly ? current.length : 0,
116
+ };
117
+ }
118
+
62
119
  /**
63
120
  * Transcript container that freezes the rendered output of every block except
64
121
  * the bottom-most (live) one on terminals where committed native scrollback is
@@ -97,11 +154,10 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
97
154
  // render. TUI extends the native-scrollback pinned region from this point
98
155
  // through the live blocks and the root chrome rendered below them.
99
156
  #nativeScrollbackLiveRegionStart: number | undefined;
100
- // Local line index up to which the leading run of live blocks is append-only
101
- // (a streaming assistant reply): everything in [liveRegionStart,
102
- // commitSafeEnd) only grows at the bottom and never re-layouts, so its
103
- // scrolled-off head is safe to commit to native scrollback. `undefined` when
104
- // the first live block is volatile (a tool preview).
157
+ // Local line index up to which the leading run of live blocks is safe to
158
+ // commit. Finalized blocks contribute their full frozen body; still-live
159
+ // blocks contribute only after their stripped render has been observed
160
+ // growing without changing a previously rendered interior row.
105
161
  #nativeScrollbackCommitSafeEnd: number | undefined;
106
162
 
107
163
  override invalidate(): void {
@@ -164,8 +220,9 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
164
220
  if (risk) this.#prevLiveStartIndex = liveStartIndex;
165
221
 
166
222
  const lines: string[] = [];
167
- // Tracks whether we are still inside the leading run of append-only live
168
- // blocks. The first non-append-only live block closes it.
223
+ // Tracks whether we are still inside the leading run of commit-safe live
224
+ // blocks. The first still-live volatile block closes it, but rendering
225
+ // continues so lower blocks remain visible.
169
226
  let commitSafeOpen = true;
170
227
  // The live-region start is recorded at the first visible row at/after the
171
228
  // cutoff; empty leading blocks (or a separator) must not claim it early.
@@ -179,24 +236,41 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
179
236
  // instead of recomputing; a stale generation (post-thaw) or width
180
237
  // mismatch (resize) recomputes, as does a block still live last frame.
181
238
  let contribution: string[] | undefined;
239
+ const previousSnapshot = risk ? child[kSnapshot] : undefined;
182
240
  if (risk && i < liveStartIndex && i < replayCutoff) {
183
- const snapshot = child[kSnapshot];
184
- if (snapshot && snapshot.generation === this.#generation && snapshot.width === width) {
185
- contribution = snapshot.lines;
241
+ if (hasValidSnapshot(previousSnapshot, width, this.#generation)) {
242
+ contribution = previousSnapshot.lines;
186
243
  }
187
244
  }
245
+ let liveCommitState: LiveCommitState | undefined;
188
246
  if (contribution === undefined) {
189
247
  const rendered = child.render(width);
190
248
  contribution = stripPlainBlankEdges(rendered);
249
+ if (risk && i >= liveStartIndex && !isBlockFinalized(child)) {
250
+ liveCommitState = deriveLiveCommitState(previousSnapshot, contribution, width, this.#generation);
251
+ }
191
252
  // Cache every block's latest contribution. While a block is in the
192
253
  // live region this keeps its snapshot current; on the frame it crosses
193
254
  // out, the recompute above refreshes it before it freezes.
194
- if (risk) child[kSnapshot] = { width, lines: contribution, generation: this.#generation };
255
+ if (risk) {
256
+ child[kSnapshot] = {
257
+ width,
258
+ lines: contribution,
259
+ generation: this.#generation,
260
+ appendOnly: liveCommitState?.appendOnly ?? false,
261
+ volatile: liveCommitState?.volatile ?? false,
262
+ };
263
+ }
195
264
  }
196
265
 
197
266
  // Empty (or stripped-to-nothing) children contribute nothing and never
198
- // affect spacing or the live-region offsets.
199
- if (contribution.length === 0) continue;
267
+ // affect spacing or the live-region offsets. An empty still-live child
268
+ // still closes the commit-safe run: if it later gains rows, it pushes
269
+ // everything below it.
270
+ if (contribution.length === 0) {
271
+ if (risk && i >= liveStartIndex && commitSafeOpen && !isBlockFinalized(child)) commitSafeOpen = false;
272
+ continue;
273
+ }
200
274
 
201
275
  // Every block is separated from preceding visible content by exactly one
202
276
  // blank row — skipped when it opens the transcript or the prior row is
@@ -212,17 +286,19 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
212
286
  }
213
287
 
214
288
  if (sep) lines.push("");
289
+ const blockStart = lines.length;
215
290
  for (let j = 0; j < contribution.length; j++) lines.push(contribution[j]!);
216
291
 
217
- // Extend the commit-safe boundary through each leading append-only live
218
- // block. The first volatile live block closes the run so its mutable
219
- // rows stay deferred.
220
292
  if (risk && i >= liveStartIndex && commitSafeOpen) {
221
- if (isBlockAppendOnly(child)) {
222
- this.#nativeScrollbackCommitSafeEnd = lines.length;
223
- } else {
224
- commitSafeOpen = false;
293
+ const finalized = isBlockFinalized(child);
294
+ const safeLength = finalized ? contribution.length : (liveCommitState?.safeLength ?? 0);
295
+ if (safeLength > 0) {
296
+ this.#nativeScrollbackCommitSafeEnd = blockStart + safeLength;
225
297
  }
298
+ // A finalized, fully safe block may let the contiguous safe run extend
299
+ // into blocks rendered below it. A still-live block keeps pushing lower
300
+ // rows around as it grows, so the run closes there.
301
+ if (!(finalized && safeLength >= contribution.length)) commitSafeOpen = false;
226
302
  }
227
303
  }
228
304
  return lines;
@@ -720,7 +720,12 @@ export class EventController {
720
720
  // seal it so it freezes (and stops animating) rather than lingering in
721
721
  // the transcript live region as a streaming preview until the next thaw.
722
722
  const component = this.ctx.pendingTools.get(toolCallId);
723
- if (component instanceof ToolExecutionComponent) component.seal();
723
+ // A foreground read still pending at turn end shares a group component
724
+ // keyed by every read's id; seal it too so a never-delivered read does
725
+ // not keep the group live (and pinning the live region) indefinitely.
726
+ if (component instanceof ToolExecutionComponent || component instanceof ReadToolGroupComponent) {
727
+ component.seal();
728
+ }
724
729
  this.ctx.pendingTools.delete(toolCallId);
725
730
  }
726
731
  }
@@ -10,6 +10,7 @@ import { expandEmoticons } from "../../modes/emoji-autocomplete";
10
10
  import { materializeImageReferenceLinks } from "../../modes/image-references";
11
11
  import { createPromptActionAutocompleteProvider } from "../../modes/prompt-action-autocomplete";
12
12
  import type { InteractiveModeContext } from "../../modes/types";
13
+ import manualContinuePrompt from "../../prompts/system/manual-continue.md" with { type: "text" };
13
14
  import { SKILL_PROMPT_MESSAGE_TYPE, type SkillPromptDetails, USER_INTERRUPT_LABEL } from "../../session/messages";
14
15
  import { executeBuiltinSlashCommand } from "../../slash-commands/builtin-registry";
15
16
  import { isTinyTitleLocalModelKey } from "../../tiny/models";
@@ -286,14 +287,21 @@ export class InputController {
286
287
 
287
288
  if (!text) return;
288
289
 
289
- // Continue shortcuts: "." or "c" sends empty message (agent continues, no visible message)
290
+ // Continue shortcuts: "." or "c" resume the agent with a hidden agent-authored
291
+ // developer directive (no visible user message) instead of an empty turn, so the
292
+ // model continues the prior intent rather than second-guessing the interrupt.
290
293
  if (text === "." || text === "c") {
291
294
  if (this.ctx.onInputCallback) {
292
295
  this.ctx.editor.setText("");
293
296
  this.ctx.pendingImages = [];
294
297
  this.ctx.pendingImageLinks = [];
295
298
  this.ctx.editor.imageLinks = undefined;
296
- this.ctx.onInputCallback({ text: "", cancelled: false, started: true });
299
+ this.ctx.onInputCallback({
300
+ text: manualContinuePrompt,
301
+ cancelled: false,
302
+ started: true,
303
+ synthetic: true,
304
+ });
297
305
  }
298
306
  return;
299
307
  }
@@ -42,6 +42,10 @@ export type SubmittedUserInput = {
42
42
  images?: ImageContent[];
43
43
  imageLinks?: (string | undefined)[];
44
44
  customType?: string;
45
+ /** Route through `session.prompt(text, { synthetic: true })` so the text lands
46
+ * as a hidden agent-authored `developer` message rather than a visible user
47
+ * turn. Used by the `c`/`.` continue shortcut. */
48
+ synthetic?: boolean;
45
49
  display?: boolean;
46
50
  cancelled: boolean;
47
51
  started: boolean;
@@ -10,6 +10,10 @@ import { CompactionSummaryMessageComponent } from "../../modes/components/compac
10
10
  import { CustomMessageComponent } from "../../modes/components/custom-message";
11
11
  import { DynamicBorder } from "../../modes/components/dynamic-border";
12
12
  import { EvalExecutionComponent } from "../../modes/components/eval-execution";
13
+ import {
14
+ type LateDiagnosticsFile,
15
+ LateDiagnosticsMessageComponent,
16
+ } from "../../modes/components/late-diagnostics-message";
13
17
  import {
14
18
  ReadToolGroupComponent,
15
19
  readArgsHaveTarget,
@@ -25,6 +29,7 @@ import type { CompactionQueuedMessage, InteractiveModeContext } from "../../mode
25
29
  import {
26
30
  type CustomMessage,
27
31
  isSilentAbort,
32
+ LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE,
28
33
  resolveAbortLabel,
29
34
  SKILL_PROMPT_MESSAGE_TYPE,
30
35
  type SkillPromptDetails,
@@ -168,6 +173,17 @@ export class UiHelpers {
168
173
  this.ctx.chatContainer.addChild(block);
169
174
  break;
170
175
  }
176
+ if (message.customType === LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE) {
177
+ const details = (
178
+ message as CustomMessage<{
179
+ files?: LateDiagnosticsFile[];
180
+ }>
181
+ ).details;
182
+ const component = new LateDiagnosticsMessageComponent(details?.files ?? []);
183
+ component.setExpanded(this.ctx.toolOutputExpanded);
184
+ this.ctx.chatContainer.addChild(component);
185
+ break;
186
+ }
171
187
  if (message.customType === SKILL_PROMPT_MESSAGE_TYPE) {
172
188
  const component = new SkillMessageComponent(message as CustomMessage<SkillPromptDetails>);
173
189
  component.setExpanded(this.ctx.toolOutputExpanded);
@@ -342,7 +358,11 @@ export class UiHelpers {
342
358
  (content.type === "thinking" && content.thinking.trim().length > 0),
343
359
  );
344
360
  if (hasVisibleAssistantContent) {
345
- readGroup?.finalize();
361
+ // Rebuild reconstructs immutable history; seal (not finalize) so the
362
+ // group freezes even if a read's result was never persisted —
363
+ // finalize alone keeps a pending entry live and would stop the whole
364
+ // transcript below it from committing to native scrollback.
365
+ readGroup?.seal();
346
366
  readGroup = null;
347
367
  }
348
368
  const isAbortedSilently = message.stopReason === "aborted" && isSilentAbort(message.errorMessage);
@@ -392,7 +412,7 @@ export class UiHelpers {
392
412
  continue;
393
413
  }
394
414
 
395
- readGroup?.finalize();
415
+ readGroup?.seal();
396
416
  readGroup = null;
397
417
  const tool = this.ctx.session.getToolByName(content.name);
398
418
  const renderArgs =
@@ -480,9 +500,10 @@ export class UiHelpers {
480
500
  }
481
501
  }
482
502
 
483
- // The trailing read run has no following break to close it; finalize so the
484
- // rebuilt group commits to native scrollback like every other historical block.
485
- readGroup?.finalize();
503
+ // The trailing read run has no following break to close it; seal so the
504
+ // rebuilt group freezes (even with a never-persisted result) and commits to
505
+ // native scrollback like every other historical block.
506
+ readGroup?.seal();
486
507
 
487
508
  // Render deferred messages (compaction summaries) at the bottom so they're visible
488
509
  for (const message of deferredMessages) {
@@ -0,0 +1,7 @@
1
+ <system-notice>
2
+ Continue. Keep going from where you left off.
3
+
4
+ - You MUST resume the most recent intent and carry the unfinished work to completion.
5
+ - Interrupted mid-step? Pick it back up from where it stopped.
6
+ - You NEVER pause to summarize progress, re-confirm the plan, or ask whether to proceed — just continue.
7
+ </system-notice>