@chances-ai/tui 12.0.0 → 14.0.0

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 (69) hide show
  1. package/dist/api-key-prompt.d.ts +1 -0
  2. package/dist/api-key-prompt.d.ts.map +1 -1
  3. package/dist/api-key-prompt.js +3 -1
  4. package/dist/api-key-prompt.js.map +1 -1
  5. package/dist/app.d.ts +14 -16
  6. package/dist/app.d.ts.map +1 -1
  7. package/dist/app.js +87 -69
  8. package/dist/app.js.map +1 -1
  9. package/dist/code-view.d.ts +9 -0
  10. package/dist/code-view.d.ts.map +1 -0
  11. package/dist/code-view.js +28 -0
  12. package/dist/code-view.js.map +1 -0
  13. package/dist/diff-model.d.ts +64 -0
  14. package/dist/diff-model.d.ts.map +1 -0
  15. package/dist/diff-model.js +156 -0
  16. package/dist/diff-model.js.map +1 -0
  17. package/dist/diff-view.d.ts +13 -0
  18. package/dist/diff-view.d.ts.map +1 -0
  19. package/dist/diff-view.js +60 -0
  20. package/dist/diff-view.js.map +1 -0
  21. package/dist/frame-scheduler.d.ts +44 -0
  22. package/dist/frame-scheduler.d.ts.map +1 -0
  23. package/dist/frame-scheduler.js +58 -0
  24. package/dist/frame-scheduler.js.map +1 -0
  25. package/dist/highlight-to-segments.d.ts +18 -0
  26. package/dist/highlight-to-segments.d.ts.map +1 -0
  27. package/dist/highlight-to-segments.js +224 -0
  28. package/dist/highlight-to-segments.js.map +1 -0
  29. package/dist/index.d.ts +13 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +14 -3
  32. package/dist/index.js.map +1 -1
  33. package/dist/markdown.d.ts +5 -0
  34. package/dist/markdown.d.ts.map +1 -0
  35. package/dist/markdown.js +117 -0
  36. package/dist/markdown.js.map +1 -0
  37. package/dist/model-picker.d.ts +1 -0
  38. package/dist/model-picker.d.ts.map +1 -1
  39. package/dist/model-picker.js +4 -2
  40. package/dist/model-picker.js.map +1 -1
  41. package/dist/progress.d.ts +41 -0
  42. package/dist/progress.d.ts.map +1 -0
  43. package/dist/progress.js +122 -0
  44. package/dist/progress.js.map +1 -0
  45. package/dist/session-picker.d.ts +1 -0
  46. package/dist/session-picker.d.ts.map +1 -1
  47. package/dist/session-picker.js +4 -2
  48. package/dist/session-picker.js.map +1 -1
  49. package/dist/theme-context.d.ts +25 -0
  50. package/dist/theme-context.d.ts.map +1 -0
  51. package/dist/theme-context.js +24 -0
  52. package/dist/theme-context.js.map +1 -0
  53. package/dist/theme.d.ts +106 -0
  54. package/dist/theme.d.ts.map +1 -0
  55. package/dist/theme.js +116 -0
  56. package/dist/theme.js.map +1 -0
  57. package/dist/tool-line.d.ts +33 -0
  58. package/dist/tool-line.d.ts.map +1 -0
  59. package/dist/tool-line.js +168 -0
  60. package/dist/tool-line.js.map +1 -0
  61. package/dist/tool-message.d.ts +18 -0
  62. package/dist/tool-message.d.ts.map +1 -0
  63. package/dist/tool-message.js +37 -0
  64. package/dist/tool-message.js.map +1 -0
  65. package/dist/view-model.d.ts +135 -3
  66. package/dist/view-model.d.ts.map +1 -1
  67. package/dist/view-model.js +292 -31
  68. package/dist/view-model.js.map +1 -1
  69. package/package.json +10 -6
@@ -1,19 +1,49 @@
1
1
  import type { ApprovalMode, ApprovalState, EventBus } from "@chances-ai/runtime";
2
2
  import type { PermissionDecision, PermissionRequest } from "@chances-ai/tools";
3
- export type LineKind = "user" | "assistant" | "tool" | "tool-result" | "error" | "info";
3
+ import { type FrameScheduler } from "./frame-scheduler.js";
4
+ /** (5.9) Extract the `linesDiff` block (from the first `@@ -d` hunk header to
5
+ * the end) out of a write/edit permission summary, or null when the summary
6
+ * carries no diff (single-line edits, "diff preview skipped", non-write tools). */
7
+ export declare function extractDiff(summary: string): string | null;
8
+ export type LineKind = "user" | "assistant" | "tool" | "error" | "info";
4
9
  export interface Line {
5
10
  kind: LineKind;
11
+ /** user/assistant/info/error body — OR, for a `tool` line, the pure arg
12
+ * summary from `formatToolCall`. */
6
13
  text: string;
14
+ /** `tool`: undefined while running, then the result's ok/err. */
7
15
  ok?: boolean;
16
+ /** `tool`: raw tool name (drives the ⏺ header + display-name mapping). */
17
+ toolName?: string;
18
+ /** `tool`: correlates the call / permission / result bus events. */
19
+ callId?: string;
20
+ /** `tool`: result preview shown under the ⎿ branch. */
21
+ result?: string;
22
+ /** `tool`: raw `linesDiff` text for write/edit, rendered under ⎿ (5.9). */
23
+ diff?: string;
24
+ /** `tool`: whether `diff` is whole-file-anchored (write) vs snippet (edit). */
25
+ anchored?: boolean;
8
26
  }
9
27
  interface Pending {
10
28
  req: PermissionRequest;
11
29
  resolve: (decision: PermissionDecision) => void;
12
30
  }
31
+ export interface ChatViewModelOptions {
32
+ /** (5.8) Injectable frame coalescer. Production uses the default ~16 ms
33
+ * `setTimeout` batcher; tests inject a manual queue for determinism. */
34
+ scheduler?: FrameScheduler;
35
+ /** (5.8) See `config.tui.toolResultPreviewLines`. Default 12. */
36
+ toolResultPreviewLines?: number;
37
+ }
13
38
  /**
14
39
  * Observable view-model. The Ink tree subscribes via useSyncExternalStore and
15
40
  * never touches domain objects directly — the only inputs are bus events and the
16
41
  * permission resolver. This is the seam that keeps the UI decoupled from core.
42
+ *
43
+ * (5.8) Rendering is split into a committed prefix (frozen scrollback fed to
44
+ * Ink `<Static>`, rendered once) and a small live tail (re-rendered each
45
+ * frame). Streamed `assistant:delta` re-renders are coalesced to one per frame
46
+ * via {@link FrameScheduler}; every structural event force-flushes immediately.
17
47
  */
18
48
  export declare class ChatViewModel {
19
49
  private readonly approval?;
@@ -23,27 +53,126 @@ export declare class ChatViewModel {
23
53
  /** (5.3) True while the Shift+Tab-into-yolo confirmation overlay is up.
24
54
  * app.tsx renders the red confirm box; `resolveYoloConfirm` clears it. */
25
55
  yoloConfirmPending: boolean;
56
+ /**
57
+ * (5.8) Count of leading `lines` that are committed (frozen). Lines
58
+ * `[0, committedCount)` are append-only — an index's content never changes
59
+ * once committed — and feed Ink `<Static>`. Lines `[committedCount, end)`
60
+ * are the live tail. Advanced ONLY forward (monotonic) so `<Static>` never
61
+ * has to un-render anything. The only line that ever mutates in place is an
62
+ * open assistant line (text grows per delta) and it is always live.
63
+ */
64
+ committedCount: number;
65
+ /**
66
+ * (5.8) Bumped by `clearLines()` and used as the Ink `<Static key>`. Ink
67
+ * `<Static>` can't be emptied by replacing its items, so `/clear` remounts
68
+ * it under a fresh key (needs `ink >= 7.0.3`, which fixes the
69
+ * remount-with-different-key drop-new-items bug).
70
+ */
71
+ clearGeneration: number;
26
72
  private version;
27
73
  private readonly listeners;
28
74
  private assistantOpen;
29
75
  private busUnsubscribers;
76
+ private readonly scheduler;
77
+ private readonly toolResultPreviewLines;
78
+ /** Non-null while a coalesced delta frame is queued (see `scheduleBump`). */
79
+ private pendingFrame;
80
+ /**
81
+ * (5.9) Diff blocks parsed from `tool:permission` summaries, keyed by callId,
82
+ * awaiting their `tool:result` so they can be attached to the tool line and
83
+ * rendered under the `⎿` branch. Emitted before `gate.evaluate` for every
84
+ * write/edit call (engine.ts:763) — so this populates even under
85
+ * auto-edit/yolo, giving transcript diffs in all approval modes with zero
86
+ * engine/contract change. Cleared on detach/clear so it can't leak.
87
+ */
88
+ private readonly diffStash;
30
89
  /**
31
90
  * (5.3) Optional session approval-mode holder. When wired (interactive
32
91
  * `chat`), the footer reflects `approvalMode` and Shift+Tab / `/approval`
33
92
  * mutate it. Left undefined in tests that don't exercise modes — every
34
93
  * approval method then no-ops and `approvalMode` reads `"default"`.
94
+ *
95
+ * (5.8) `options` injects the frame scheduler + tool-result preview length;
96
+ * both default so existing callers (`new ChatViewModel()`,
97
+ * `new ChatViewModel(approval)`) keep working unchanged.
35
98
  */
36
- constructor(approval?: ApprovalState | undefined);
99
+ constructor(approval?: ApprovalState | undefined, options?: ChatViewModelOptions);
37
100
  subscribe: (fn: () => void) => (() => void);
38
101
  getSnapshot: () => number;
102
+ /**
103
+ * (5.8) The frozen scrollback prefix — fed to Ink `<Static>` and rendered
104
+ * once. Guaranteed append-only: the element at index `i` never changes once
105
+ * it appears here. A fresh array is returned each call, but the prefix
106
+ * content is stable, which is all `<Static>` relies on.
107
+ */
108
+ committedLines(): Line[];
109
+ /** (5.8) The live tail re-rendered each frame: the open assistant line while
110
+ * streaming, or an in-flight tool / tool-result line awaiting its terminal
111
+ * event. Usually 0–1 entries. */
112
+ liveLines(): Line[];
113
+ /** (5.9) The still-live `tool` line for a callId (normally the last line).
114
+ * Returns a mutable ref so `tool:result` can fill it in place — safe because
115
+ * it is in the live region (index ≥ committedCount), not yet committed. */
116
+ private findLiveTool;
117
+ /**
118
+ * (5.9 codex R2 MUST-1) Advance `committedCount` toward `target` but NEVER
119
+ * past the earliest still-running tool line (`kind:"tool"` with `ok ===
120
+ * undefined`). A sync/background subagent emits its own `tool:call` /
121
+ * `assistant:delta` frames on the SAME bus BETWEEN a parent `task` tool's
122
+ * call and its result; without this clamp those frames would commit the
123
+ * parent's tool line, after which `findLiveTool` could no longer fill it
124
+ * (mutating a committed line breaks the `<Static>` append-only invariant) and
125
+ * the parent result would spawn a duplicate empty block. Keeping every
126
+ * unresolved tool line live until its result arrives makes the fill always
127
+ * append-only-safe. `turn:end` force-commits unconditionally (the turn is
128
+ * over). Monotonic: `target ≥ committedCount`, and the result is in
129
+ * `[committedCount, target]`.
130
+ */
131
+ private commitThrough;
39
132
  private bump;
133
+ /**
134
+ * (5.8) Coalesce a streamed-token re-render onto the next frame. The first
135
+ * delta in a window arms the scheduler; later deltas in the same window are
136
+ * no-ops (the text is already accumulated on the line), so N tokens cause
137
+ * at most one render per frame instead of N.
138
+ */
139
+ private scheduleBump;
140
+ /** (5.8) Drop a queued delta frame if one is pending. */
141
+ private cancelPendingBump;
142
+ /**
143
+ * (5.8) Immediate notify that first drops any pending coalesced delta frame.
144
+ * Used by every structural mutation (finished message, tool call/result,
145
+ * turn boundary, user action) so the UI never renders a structural change
146
+ * behind a stale token-stream frame, and a cancelled frame can't fire into a
147
+ * later epoch.
148
+ */
149
+ private forceFlush;
150
+ /**
151
+ * Push a standalone, immutable line and commit everything (it and any prior
152
+ * live tail are terminal-on-creation).
153
+ *
154
+ * Closing `assistantOpen` here means a standalone line pushed while a stream
155
+ * is open ends that stream: the next `assistant:delta` opens a FRESH line
156
+ * rather than appending to the committed one — so the committed line is
157
+ * genuinely final (append-only holds; see the interleaving regression test).
158
+ * That a mid-stream user submit / mode-cycle visually splits the assistant
159
+ * message is a pre-existing interaction behavior (NOT a v13 regression — the
160
+ * pre-v13 `pushUser` closed the stream the same way); richer mid-turn input
161
+ * handling (queueing) is a v15 interaction-model concern, out of v13 scope.
162
+ */
163
+ private pushCommitted;
40
164
  pushUser(text: string): void;
41
165
  pushInfo(text: string): void;
42
166
  pushError(text: string): void;
43
167
  /** Empties the rendered scrollback — used by `/clear` so the user sees a
44
168
  * fresh view alongside the session.clearTurns() that drops the conversation
45
169
  * history. The view-model and the underlying session are intentionally
46
- * separate operations; the slash command sequences both. */
170
+ * separate operations; the slash command sequences both.
171
+ *
172
+ * (5.8) Because Ink `<Static>` keeps everything ever handed to it, emptying
173
+ * `lines` is not enough — `clearGeneration` is bumped so app.tsx can remount
174
+ * `<Static>` under a fresh key. Any pending delta frame is dropped so it
175
+ * can't fire into the cleared epoch. */
47
176
  clearLines(): void;
48
177
  attach(bus: EventBus): void;
49
178
  /**
@@ -56,6 +185,9 @@ export declare class ChatViewModel {
56
185
  * prompt that will never reach a user. Any *future* call to
57
186
  * `requestPermission` on this detached VM short-circuits the same
58
187
  * way — same reasoning.
188
+ *
189
+ * (5.8) Also drops any pending coalesced frame so a scheduled bump
190
+ * can't fire into a remounted/stale epoch.
59
191
  */
60
192
  detach(): void;
61
193
  private detached;
@@ -1 +1 @@
1
- {"version":3,"file":"view-model.d.ts","sourceRoot":"","sources":["../src/view-model.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AACjF,OAAO,KAAK,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAE/E,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,WAAW,GAAG,MAAM,GAAG,aAAa,GAAG,OAAO,GAAG,MAAM,CAAC;AACxF,MAAM,WAAW,IAAI;IACnB,IAAI,EAAE,QAAQ,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,CAAC,EAAE,OAAO,CAAC;CACd;AAED,UAAU,OAAO;IACf,GAAG,EAAE,iBAAiB,CAAC;IACvB,OAAO,EAAE,CAAC,QAAQ,EAAE,kBAAkB,KAAK,IAAI,CAAC;CACjD;AAED;;;;GAIG;AACH,qBAAa,aAAa;IAmBZ,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC;IAlBtC,KAAK,EAAE,IAAI,EAAE,CAAM;IACnB,IAAI,UAAS;IACb,OAAO,EAAE,OAAO,GAAG,IAAI,CAAQ;IAC/B;+EAC2E;IAC3E,kBAAkB,UAAS;IAE3B,OAAO,CAAC,OAAO,CAAK;IACpB,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAyB;IACnD,OAAO,CAAC,aAAa,CAAS;IAC9B,OAAO,CAAC,gBAAgB,CAAyB;IAEjD;;;;;OAKG;gBAC0B,QAAQ,CAAC,EAAE,aAAa,YAAA;IAErD,SAAS,GAAI,IAAI,MAAM,IAAI,KAAG,CAAC,MAAM,IAAI,CAAC,CAGxC;IAEF,WAAW,QAAO,MAAM,CAAiB;IAEzC,OAAO,CAAC,IAAI;IAKZ,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAM5B,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAM5B,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAM7B;;;gEAG4D;IAC5D,UAAU,IAAI,IAAI;IAMlB,MAAM,CAAC,GAAG,EAAE,QAAQ,GAAG,IAAI;IA4C3B;;;;;;;;;;OAUG;IACH,MAAM,IAAI,IAAI;IAUd,OAAO,CAAC,QAAQ,CAAS;IAEzB;4EACwE;IACxE,iBAAiB,GAAI,KAAK,iBAAiB,KAAG,OAAO,CAAC,kBAAkB,CAAC,CAMvE;IAEF;;;;;;;OAOG;IACH,iBAAiB,CAAC,QAAQ,EAAE,kBAAkB,GAAG,IAAI;IAmBrD,oEAAoE;IACpE,IAAI,YAAY,IAAI,YAAY,CAE/B;IAED;;;;;OAKG;IACH,iBAAiB,IAAI,IAAI;IAazB,gFAAgF;IAChF,kBAAkB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI;CAS3C"}
1
+ {"version":3,"file":"view-model.d.ts","sourceRoot":"","sources":["../src/view-model.ts"],"names":[],"mappings":"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"}
@@ -1,7 +1,26 @@
1
+ import { defaultFrameScheduler, } from "./frame-scheduler.js";
2
+ import { formatToolCall } from "./tool-line.js";
3
+ /** (5.9) Extract the `linesDiff` block (from the first `@@ -d` hunk header to
4
+ * the end) out of a write/edit permission summary, or null when the summary
5
+ * carries no diff (single-line edits, "diff preview skipped", non-write tools). */
6
+ export function extractDiff(summary) {
7
+ const lines = summary.split("\n");
8
+ const idx = lines.findIndex((l) => /^@@ -\d/.test(l));
9
+ return idx < 0 ? null : lines.slice(idx).join("\n");
10
+ }
11
+ /** (5.8) Default count of leading tool-result lines shown inline before the
12
+ * rest collapses to a `… +N more lines` note. Overridable via
13
+ * `config.tui.toolResultPreviewLines`. */
14
+ const DEFAULT_TOOL_RESULT_PREVIEW_LINES = 12;
1
15
  /**
2
16
  * Observable view-model. The Ink tree subscribes via useSyncExternalStore and
3
17
  * never touches domain objects directly — the only inputs are bus events and the
4
18
  * permission resolver. This is the seam that keeps the UI decoupled from core.
19
+ *
20
+ * (5.8) Rendering is split into a committed prefix (frozen scrollback fed to
21
+ * Ink `<Static>`, rendered once) and a small live tail (re-rendered each
22
+ * frame). Streamed `assistant:delta` re-renders are coalesced to one per frame
23
+ * via {@link FrameScheduler}; every structural event force-flushes immediately.
5
24
  */
6
25
  export class ChatViewModel {
7
26
  approval;
@@ -11,51 +30,195 @@ export class ChatViewModel {
11
30
  /** (5.3) True while the Shift+Tab-into-yolo confirmation overlay is up.
12
31
  * app.tsx renders the red confirm box; `resolveYoloConfirm` clears it. */
13
32
  yoloConfirmPending = false;
33
+ /**
34
+ * (5.8) Count of leading `lines` that are committed (frozen). Lines
35
+ * `[0, committedCount)` are append-only — an index's content never changes
36
+ * once committed — and feed Ink `<Static>`. Lines `[committedCount, end)`
37
+ * are the live tail. Advanced ONLY forward (monotonic) so `<Static>` never
38
+ * has to un-render anything. The only line that ever mutates in place is an
39
+ * open assistant line (text grows per delta) and it is always live.
40
+ */
41
+ committedCount = 0;
42
+ /**
43
+ * (5.8) Bumped by `clearLines()` and used as the Ink `<Static key>`. Ink
44
+ * `<Static>` can't be emptied by replacing its items, so `/clear` remounts
45
+ * it under a fresh key (needs `ink >= 7.0.3`, which fixes the
46
+ * remount-with-different-key drop-new-items bug).
47
+ */
48
+ clearGeneration = 0;
14
49
  version = 0;
15
50
  listeners = new Set();
16
51
  assistantOpen = false;
17
52
  busUnsubscribers = [];
53
+ scheduler;
54
+ toolResultPreviewLines;
55
+ /** Non-null while a coalesced delta frame is queued (see `scheduleBump`). */
56
+ pendingFrame = null;
57
+ /**
58
+ * (5.9) Diff blocks parsed from `tool:permission` summaries, keyed by callId,
59
+ * awaiting their `tool:result` so they can be attached to the tool line and
60
+ * rendered under the `⎿` branch. Emitted before `gate.evaluate` for every
61
+ * write/edit call (engine.ts:763) — so this populates even under
62
+ * auto-edit/yolo, giving transcript diffs in all approval modes with zero
63
+ * engine/contract change. Cleared on detach/clear so it can't leak.
64
+ */
65
+ diffStash = new Map();
18
66
  /**
19
67
  * (5.3) Optional session approval-mode holder. When wired (interactive
20
68
  * `chat`), the footer reflects `approvalMode` and Shift+Tab / `/approval`
21
69
  * mutate it. Left undefined in tests that don't exercise modes — every
22
70
  * approval method then no-ops and `approvalMode` reads `"default"`.
71
+ *
72
+ * (5.8) `options` injects the frame scheduler + tool-result preview length;
73
+ * both default so existing callers (`new ChatViewModel()`,
74
+ * `new ChatViewModel(approval)`) keep working unchanged.
23
75
  */
24
- constructor(approval) {
76
+ constructor(approval, options) {
25
77
  this.approval = approval;
78
+ this.scheduler = options?.scheduler ?? defaultFrameScheduler;
79
+ this.toolResultPreviewLines =
80
+ options?.toolResultPreviewLines ?? DEFAULT_TOOL_RESULT_PREVIEW_LINES;
26
81
  }
27
82
  subscribe = (fn) => {
28
83
  this.listeners.add(fn);
29
84
  return () => this.listeners.delete(fn);
30
85
  };
31
86
  getSnapshot = () => this.version;
87
+ /**
88
+ * (5.8) The frozen scrollback prefix — fed to Ink `<Static>` and rendered
89
+ * once. Guaranteed append-only: the element at index `i` never changes once
90
+ * it appears here. A fresh array is returned each call, but the prefix
91
+ * content is stable, which is all `<Static>` relies on.
92
+ */
93
+ committedLines() {
94
+ return this.lines.slice(0, this.committedCount);
95
+ }
96
+ /** (5.8) The live tail re-rendered each frame: the open assistant line while
97
+ * streaming, or an in-flight tool / tool-result line awaiting its terminal
98
+ * event. Usually 0–1 entries. */
99
+ liveLines() {
100
+ return this.lines.slice(this.committedCount);
101
+ }
102
+ /** (5.9) The still-live `tool` line for a callId (normally the last line).
103
+ * Returns a mutable ref so `tool:result` can fill it in place — safe because
104
+ * it is in the live region (index ≥ committedCount), not yet committed. */
105
+ findLiveTool(callId) {
106
+ for (let i = this.lines.length - 1; i >= this.committedCount; i--) {
107
+ const l = this.lines[i];
108
+ if (l && l.kind === "tool" && l.callId === callId)
109
+ return l;
110
+ }
111
+ return undefined;
112
+ }
113
+ /**
114
+ * (5.9 codex R2 MUST-1) Advance `committedCount` toward `target` but NEVER
115
+ * past the earliest still-running tool line (`kind:"tool"` with `ok ===
116
+ * undefined`). A sync/background subagent emits its own `tool:call` /
117
+ * `assistant:delta` frames on the SAME bus BETWEEN a parent `task` tool's
118
+ * call and its result; without this clamp those frames would commit the
119
+ * parent's tool line, after which `findLiveTool` could no longer fill it
120
+ * (mutating a committed line breaks the `<Static>` append-only invariant) and
121
+ * the parent result would spawn a duplicate empty block. Keeping every
122
+ * unresolved tool line live until its result arrives makes the fill always
123
+ * append-only-safe. `turn:end` force-commits unconditionally (the turn is
124
+ * over). Monotonic: `target ≥ committedCount`, and the result is in
125
+ * `[committedCount, target]`.
126
+ */
127
+ commitThrough(target) {
128
+ let limit = target;
129
+ for (let i = this.committedCount; i < target; i++) {
130
+ const l = this.lines[i];
131
+ if (l && l.kind === "tool" && l.ok === undefined) {
132
+ limit = i;
133
+ break;
134
+ }
135
+ }
136
+ if (limit > this.committedCount)
137
+ this.committedCount = limit;
138
+ }
32
139
  bump() {
33
140
  this.version++;
34
141
  for (const l of this.listeners)
35
142
  l();
36
143
  }
37
- pushUser(text) {
38
- this.lines.push({ kind: "user", text });
39
- this.assistantOpen = false;
144
+ /**
145
+ * (5.8) Coalesce a streamed-token re-render onto the next frame. The first
146
+ * delta in a window arms the scheduler; later deltas in the same window are
147
+ * no-ops (the text is already accumulated on the line), so N tokens cause
148
+ * at most one render per frame instead of N.
149
+ */
150
+ scheduleBump() {
151
+ if (this.pendingFrame !== null)
152
+ return;
153
+ this.pendingFrame = this.scheduler.schedule(() => {
154
+ this.pendingFrame = null;
155
+ this.bump();
156
+ });
157
+ }
158
+ /** (5.8) Drop a queued delta frame if one is pending. */
159
+ cancelPendingBump() {
160
+ if (this.pendingFrame !== null) {
161
+ this.scheduler.cancel(this.pendingFrame);
162
+ this.pendingFrame = null;
163
+ }
164
+ }
165
+ /**
166
+ * (5.8) Immediate notify that first drops any pending coalesced delta frame.
167
+ * Used by every structural mutation (finished message, tool call/result,
168
+ * turn boundary, user action) so the UI never renders a structural change
169
+ * behind a stale token-stream frame, and a cancelled frame can't fire into a
170
+ * later epoch.
171
+ */
172
+ forceFlush() {
173
+ this.cancelPendingBump();
40
174
  this.bump();
41
175
  }
42
- pushInfo(text) {
43
- this.lines.push({ kind: "info", text });
176
+ /**
177
+ * Push a standalone, immutable line and commit everything (it and any prior
178
+ * live tail are terminal-on-creation).
179
+ *
180
+ * Closing `assistantOpen` here means a standalone line pushed while a stream
181
+ * is open ends that stream: the next `assistant:delta` opens a FRESH line
182
+ * rather than appending to the committed one — so the committed line is
183
+ * genuinely final (append-only holds; see the interleaving regression test).
184
+ * That a mid-stream user submit / mode-cycle visually splits the assistant
185
+ * message is a pre-existing interaction behavior (NOT a v13 regression — the
186
+ * pre-v13 `pushUser` closed the stream the same way); richer mid-turn input
187
+ * handling (queueing) is a v15 interaction-model concern, out of v13 scope.
188
+ */
189
+ pushCommitted(line) {
44
190
  this.assistantOpen = false;
45
- this.bump();
191
+ this.lines.push(line);
192
+ this.committedCount = this.lines.length;
193
+ }
194
+ pushUser(text) {
195
+ this.pushCommitted({ kind: "user", text });
196
+ this.forceFlush();
197
+ }
198
+ pushInfo(text) {
199
+ this.pushCommitted({ kind: "info", text });
200
+ this.forceFlush();
46
201
  }
47
202
  pushError(text) {
48
- this.lines.push({ kind: "error", text });
49
- this.assistantOpen = false;
50
- this.bump();
203
+ this.pushCommitted({ kind: "error", text });
204
+ this.forceFlush();
51
205
  }
52
206
  /** Empties the rendered scrollback — used by `/clear` so the user sees a
53
207
  * fresh view alongside the session.clearTurns() that drops the conversation
54
208
  * history. The view-model and the underlying session are intentionally
55
- * separate operations; the slash command sequences both. */
209
+ * separate operations; the slash command sequences both.
210
+ *
211
+ * (5.8) Because Ink `<Static>` keeps everything ever handed to it, emptying
212
+ * `lines` is not enough — `clearGeneration` is bumped so app.tsx can remount
213
+ * `<Static>` under a fresh key. Any pending delta frame is dropped so it
214
+ * can't fire into the cleared epoch. */
56
215
  clearLines() {
216
+ this.cancelPendingBump();
57
217
  this.lines = [];
218
+ this.committedCount = 0;
219
+ this.clearGeneration++;
58
220
  this.assistantOpen = false;
221
+ this.diffStash.clear();
59
222
  this.bump();
60
223
  }
61
224
  attach(bus) {
@@ -69,36 +232,92 @@ export class ChatViewModel {
69
232
  this.busUnsubscribers = [
70
233
  bus.on("turn:start", () => {
71
234
  this.busy = true;
72
- this.bump();
235
+ this.forceFlush();
73
236
  }),
74
237
  bus.on("turn:end", () => {
75
238
  this.busy = false;
76
- this.bump();
239
+ // Flush the whole live region: the turn is over, nothing more will
240
+ // change any trailing line.
241
+ this.committedCount = this.lines.length;
242
+ this.forceFlush();
77
243
  }),
78
244
  bus.on("assistant:delta", (e) => {
79
245
  if (!this.assistantOpen) {
246
+ // Commit any prior live tail before the new narration line opens
247
+ // (but not past an unresolved tool line — see `commitThrough`).
248
+ this.commitThrough(this.lines.length);
80
249
  this.lines.push({ kind: "assistant", text: "" });
81
250
  this.assistantOpen = true;
82
251
  }
83
252
  this.lines[this.lines.length - 1].text += e.text;
84
- this.bump();
253
+ // Coalesce: at most one render per frame for a fast token stream.
254
+ this.scheduleBump();
85
255
  }),
86
256
  bus.on("assistant:message", () => {
257
+ // The streamed line is final — commit it (not past an unresolved tool).
87
258
  this.assistantOpen = false;
88
- this.bump();
259
+ this.commitThrough(this.lines.length);
260
+ this.forceFlush();
89
261
  }),
90
262
  bus.on("tool:call", (e) => {
91
- this.lines.push({ kind: "tool", text: `${e.name} ${JSON.stringify(e.args)}` });
263
+ // A tool-using turn emits NO `assistant:message`, so closing the open
264
+ // assistant line here is what commits the narration (codex R1 MF-2).
265
+ // (5.9) One line per tool call: the same line later gains its result /
266
+ // diff (filled in `tool:result` while still LIVE), so the `⏺` dot can
267
+ // reflect the final ok/err without ever mutating a committed line.
92
268
  this.assistantOpen = false;
93
- this.bump();
269
+ this.lines.push({
270
+ kind: "tool",
271
+ toolName: e.name,
272
+ text: formatToolCall(e.name, e.args),
273
+ callId: e.callId,
274
+ });
275
+ // Commit prior lines, but not past an earlier still-running tool line
276
+ // (e.g. the parent `task` line while a subagent streams).
277
+ this.commitThrough(this.lines.length - 1);
278
+ this.forceFlush();
279
+ }),
280
+ bus.on("tool:permission", (e) => {
281
+ // (5.9) Stash the diff for the matching tool:result. No UI change here —
282
+ // the prompt overlay reads `vm.pending.req.summary` directly.
283
+ const diff = extractDiff(e.summary);
284
+ if (diff !== null)
285
+ this.diffStash.set(e.callId, { diff, anchored: e.name === "write" });
94
286
  }),
95
287
  bus.on("tool:result", (e) => {
96
- this.lines.push({ kind: "tool-result", ok: e.ok, text: clip(e.output) });
97
- this.bump();
288
+ // (5.9) Fill the still-live tool line (append-only safe) and commit the
289
+ // now-complete block. Attach any stashed diff (write/edit).
290
+ const stash = this.diffStash.get(e.callId);
291
+ this.diffStash.delete(e.callId);
292
+ const line = this.findLiveTool(e.callId);
293
+ const result = previewToolResult(e.output, this.toolResultPreviewLines);
294
+ if (line) {
295
+ line.ok = e.ok;
296
+ line.result = result;
297
+ if (stash) {
298
+ line.diff = stash.diff;
299
+ line.anchored = stash.anchored;
300
+ }
301
+ }
302
+ else {
303
+ // No matching live call line (defensive) — push a standalone block.
304
+ this.lines.push({
305
+ kind: "tool",
306
+ toolName: e.name,
307
+ text: "",
308
+ ok: e.ok,
309
+ result,
310
+ ...(stash ? { diff: stash.diff, anchored: stash.anchored } : {}),
311
+ });
312
+ }
313
+ // The filled line is now resolved; commit up to it (but still not past
314
+ // any EARLIER unresolved tool line — e.g. an outer parent task).
315
+ this.commitThrough(this.lines.length);
316
+ this.forceFlush();
98
317
  }),
99
318
  bus.on("error", (e) => {
100
- this.lines.push({ kind: "error", text: `${e.code}: ${e.message}` });
101
- this.bump();
319
+ this.pushCommitted({ kind: "error", text: `${e.code}: ${e.message}` });
320
+ this.forceFlush();
102
321
  }),
103
322
  ];
104
323
  }
@@ -112,11 +331,16 @@ export class ChatViewModel {
112
331
  * prompt that will never reach a user. Any *future* call to
113
332
  * `requestPermission` on this detached VM short-circuits the same
114
333
  * way — same reasoning.
334
+ *
335
+ * (5.8) Also drops any pending coalesced frame so a scheduled bump
336
+ * can't fire into a remounted/stale epoch.
115
337
  */
116
338
  detach() {
117
339
  for (const unsubscribe of this.busUnsubscribers.splice(0))
118
340
  unsubscribe();
119
341
  this.detached = true;
342
+ this.cancelPendingBump();
343
+ this.diffStash.clear();
120
344
  if (this.pending) {
121
345
  const p = this.pending;
122
346
  this.pending = null;
@@ -131,7 +355,7 @@ export class ChatViewModel {
131
355
  return Promise.resolve({ allow: false });
132
356
  return new Promise((resolve) => {
133
357
  this.pending = { req, resolve };
134
- this.bump();
358
+ this.forceFlush();
135
359
  });
136
360
  };
137
361
  /**
@@ -153,10 +377,10 @@ export class ChatViewModel {
153
377
  : decision.feedback
154
378
  ? "denied with feedback"
155
379
  : "denied";
156
- this.lines.push({ kind: "info", text: `${verb}: ${p.req.summary}` });
380
+ this.pushCommitted({ kind: "info", text: `${verb}: ${p.req.summary}` });
157
381
  p.resolve(decision);
158
382
  }
159
- this.bump();
383
+ this.forceFlush();
160
384
  }
161
385
  // -- approval mode (5.3) ----------------------------------------------------
162
386
  /** The current session approval mode (`"default"` when unwired). */
@@ -175,12 +399,12 @@ export class ChatViewModel {
175
399
  const next = this.approval.next();
176
400
  if (next === "yolo" && !this.approval.isYoloConfirmed()) {
177
401
  this.yoloConfirmPending = true;
178
- this.bump();
402
+ this.forceFlush();
179
403
  return;
180
404
  }
181
405
  this.approval.set(next);
182
- this.lines.push({ kind: "info", text: `approval mode → ${next}` });
183
- this.bump();
406
+ this.pushCommitted({ kind: "info", text: `approval mode → ${next}` });
407
+ this.forceFlush();
184
408
  }
185
409
  /** Resolve the yolo confirm overlay. `confirm` latches yolo for the session. */
186
410
  resolveYoloConfirm(confirm) {
@@ -188,12 +412,49 @@ export class ChatViewModel {
188
412
  if (confirm && this.approval) {
189
413
  this.approval.confirmYolo();
190
414
  this.approval.set("yolo");
191
- this.lines.push({ kind: "info", text: "approval mode → yolo (all tool calls auto-approved)" });
415
+ this.pushCommitted({
416
+ kind: "info",
417
+ text: "approval mode → yolo (all tool calls auto-approved)",
418
+ });
192
419
  }
193
- this.bump();
420
+ this.forceFlush();
194
421
  }
195
422
  }
196
- function clip(s, n = 400) {
197
- return s.length > n ? s.slice(0, n) + "…" : s;
423
+ /** Secondary guard for {@link previewToolResult}: a generous total-character
424
+ * ceiling so a single very long line (e.g. minified JSON with no newlines)
425
+ * can't blow up the live render even though it passes the line-count check.
426
+ * Far larger than the old hard 400-char clip — it only bites pathological
427
+ * single lines, never normal multi-line diffs / command output. The full
428
+ * output is already on disk when it overflowed (the result banner). */
429
+ const MAX_PREVIEW_CHARS = 8000;
430
+ /**
431
+ * (5.8) Line-aware tool-result preview: show the first `maxLines` content
432
+ * lines, then collapse the rest to a `… +N more lines` note. Replaces the old
433
+ * hard 400-char clip, which truncated multi-line diffs / command output
434
+ * mid-line. When a tool's full output overflowed it was already persisted to
435
+ * disk and the path is part of `output` (the banner), so nothing is lost.
436
+ *
437
+ * A single trailing newline is not counted as a hidden line (codex R2
438
+ * SHOULD-FIX): `"a\nb\n"` is two content lines, not three. After the
439
+ * line-aware pass, {@link MAX_PREVIEW_CHARS} caps a pathological single huge
440
+ * line (codex R2 NICE).
441
+ */
442
+ function previewToolResult(output, maxLines) {
443
+ const lines = output.split("\n");
444
+ // A terminal "\n" yields a final empty segment that isn't a content line.
445
+ const contentLineCount = lines.length > 0 && lines[lines.length - 1] === "" ? lines.length - 1 : lines.length;
446
+ let preview;
447
+ if (contentLineCount <= maxLines) {
448
+ preview = output;
449
+ }
450
+ else {
451
+ const hidden = contentLineCount - maxLines;
452
+ preview = `${lines.slice(0, maxLines).join("\n")}\n… +${hidden} more line${hidden === 1 ? "" : "s"}`;
453
+ }
454
+ if (preview.length > MAX_PREVIEW_CHARS) {
455
+ const hiddenChars = preview.length - MAX_PREVIEW_CHARS;
456
+ preview = `${preview.slice(0, MAX_PREVIEW_CHARS)}… +${hiddenChars} more characters`;
457
+ }
458
+ return preview;
198
459
  }
199
460
  //# sourceMappingURL=view-model.js.map