@dungle-scrubs/tallow 0.8.27 → 0.9.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 (99) hide show
  1. package/README.md +42 -1
  2. package/dist/cli.js +7 -1
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config.d.ts +1 -1
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +1 -1
  7. package/dist/config.js.map +1 -1
  8. package/dist/install.d.ts.map +1 -1
  9. package/dist/install.js +2 -9
  10. package/dist/install.js.map +1 -1
  11. package/dist/interactive-mode-patch.d.ts.map +1 -1
  12. package/dist/interactive-mode-patch.js +20 -9
  13. package/dist/interactive-mode-patch.js.map +1 -1
  14. package/dist/model-metadata-overrides.d.ts +2 -5
  15. package/dist/model-metadata-overrides.d.ts.map +1 -1
  16. package/dist/model-metadata-overrides.js +23 -12
  17. package/dist/model-metadata-overrides.js.map +1 -1
  18. package/dist/sdk.d.ts.map +1 -1
  19. package/dist/sdk.js +20 -9
  20. package/dist/sdk.js.map +1 -1
  21. package/dist/workspace-transition-interactive.d.ts.map +1 -1
  22. package/dist/workspace-transition-interactive.js +53 -3
  23. package/dist/workspace-transition-interactive.js.map +1 -1
  24. package/dist/workspace-transition.d.ts +2 -1
  25. package/dist/workspace-transition.d.ts.map +1 -1
  26. package/dist/workspace-transition.js +16 -4
  27. package/dist/workspace-transition.js.map +1 -1
  28. package/extensions/__integration__/cd-tool-guidelines.test.ts +46 -0
  29. package/extensions/__integration__/welcome-screen.test.ts +240 -0
  30. package/extensions/_icons/__tests__/icons.test.ts +0 -1
  31. package/extensions/_icons/index.ts +0 -2
  32. package/extensions/_shared/pid-registry.ts +5 -5
  33. package/extensions/background-task-tool/index.ts +1 -1
  34. package/extensions/cd-tool/index.ts +4 -1
  35. package/extensions/context-fork/__tests__/context-fork.test.ts +9 -0
  36. package/extensions/edit-tool-enhanced/index.ts +3 -1
  37. package/extensions/health/__tests__/diagnostics.test.ts +25 -0
  38. package/extensions/health/index.ts +62 -1
  39. package/extensions/loop/__tests__/loop.test.ts +365 -1
  40. package/extensions/loop/index.ts +213 -3
  41. package/extensions/prompt-suggestions/__tests__/autocomplete.test.ts +111 -3
  42. package/extensions/prompt-suggestions/autocomplete.ts +23 -5
  43. package/extensions/prompt-suggestions/index.ts +62 -3
  44. package/extensions/read-tool-enhanced/index.ts +5 -1
  45. package/extensions/render-stabilizer/__tests__/render-stabilizer.test.ts +42 -0
  46. package/extensions/render-stabilizer/extension.json +5 -0
  47. package/extensions/render-stabilizer/index.ts +66 -0
  48. package/extensions/session-memory/index.ts +1 -1
  49. package/extensions/session-namer/index.ts +1 -1
  50. package/extensions/subagent-tool/__tests__/auto-cheap-model.test.ts +66 -6
  51. package/extensions/subagent-tool/__tests__/model-router-explicit-resolution.test.ts +79 -5
  52. package/extensions/subagent-tool/__tests__/presentation-rendering.test.ts +4 -4
  53. package/extensions/subagent-tool/index.ts +4 -2
  54. package/extensions/subagent-tool/process.ts +26 -8
  55. package/extensions/teams-tool/sessions/spawn.ts +2 -2
  56. package/extensions/welcome-screen/__tests__/welcome-screen.test.ts +35 -0
  57. package/extensions/welcome-screen/extension.json +20 -0
  58. package/extensions/welcome-screen/index.ts +189 -0
  59. package/node_modules/@mariozechner/pi-tui/dist/index.d.ts +2 -2
  60. package/node_modules/@mariozechner/pi-tui/dist/index.d.ts.map +1 -1
  61. package/node_modules/@mariozechner/pi-tui/dist/index.js +2 -2
  62. package/node_modules/@mariozechner/pi-tui/dist/index.js.map +1 -1
  63. package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts +309 -25
  64. package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts.map +1 -1
  65. package/node_modules/@mariozechner/pi-tui/dist/keybindings.js +392 -72
  66. package/node_modules/@mariozechner/pi-tui/dist/keybindings.js.map +1 -1
  67. package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts +30 -0
  68. package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts.map +1 -1
  69. package/node_modules/@mariozechner/pi-tui/dist/keys.js +50 -6
  70. package/node_modules/@mariozechner/pi-tui/dist/keys.js.map +1 -1
  71. package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts +27 -0
  72. package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts.map +1 -1
  73. package/node_modules/@mariozechner/pi-tui/dist/terminal.js +59 -4
  74. package/node_modules/@mariozechner/pi-tui/dist/terminal.js.map +1 -1
  75. package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts +56 -0
  76. package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts.map +1 -1
  77. package/node_modules/@mariozechner/pi-tui/dist/tui.js +188 -5
  78. package/node_modules/@mariozechner/pi-tui/dist/tui.js.map +1 -1
  79. package/node_modules/@mariozechner/pi-tui/package.json +1 -1
  80. package/node_modules/@mariozechner/pi-tui/src/__tests__/mouse-events.test.ts +134 -0
  81. package/node_modules/@mariozechner/pi-tui/src/__tests__/tmux-compat.test.ts +204 -0
  82. package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-diff-regression.test.ts +49 -0
  83. package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-render-scheduling.test.ts +2 -0
  84. package/node_modules/@mariozechner/pi-tui/src/index.ts +11 -0
  85. package/node_modules/@mariozechner/pi-tui/src/keybindings.ts +478 -140
  86. package/node_modules/@mariozechner/pi-tui/src/keys.ts +84 -6
  87. package/node_modules/@mariozechner/pi-tui/src/terminal.ts +69 -4
  88. package/node_modules/@mariozechner/pi-tui/src/tui.ts +205 -5
  89. package/package.json +9 -9
  90. package/runtime/config.ts +7 -0
  91. package/runtime/model-metadata-overrides.ts +7 -0
  92. package/schemas/settings.schema.json +0 -5
  93. package/skills/tallow-expert/SKILL.md +6 -4
  94. package/extensions/plan-mode-tool/__tests__/e2e.mjs +0 -350
  95. package/extensions/plan-mode-tool/__tests__/index.test.ts +0 -213
  96. package/extensions/plan-mode-tool/__tests__/utils.test.ts +0 -381
  97. package/extensions/plan-mode-tool/extension.json +0 -22
  98. package/extensions/plan-mode-tool/index.ts +0 -583
  99. package/extensions/plan-mode-tool/utils.ts +0 -257
@@ -819,7 +819,10 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
819
819
  if (modifier === 0) {
820
820
  return data === "\t" || matchesKittySequence(data, CODEPOINTS.tab, 0);
821
821
  }
822
- return matchesKittySequence(data, CODEPOINTS.tab, modifier);
822
+ return (
823
+ matchesKittySequence(data, CODEPOINTS.tab, modifier) ||
824
+ matchesModifyOtherKeys(data, CODEPOINTS.tab, modifier)
825
+ );
823
826
 
824
827
  case "enter":
825
828
  case "return":
@@ -873,7 +876,8 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
873
876
  }
874
877
  return (
875
878
  matchesKittySequence(data, CODEPOINTS.enter, modifier) ||
876
- matchesKittySequence(data, CODEPOINTS.kpEnter, modifier)
879
+ matchesKittySequence(data, CODEPOINTS.kpEnter, modifier) ||
880
+ matchesModifyOtherKeys(data, CODEPOINTS.enter, modifier)
877
881
  );
878
882
 
879
883
  case "backspace":
@@ -1108,21 +1112,33 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
1108
1112
  if (ctrl && !shift && !alt) {
1109
1113
  // Legacy: ctrl+key sends the control character
1110
1114
  if (rawCtrl && data === rawCtrl) return true;
1111
- return matchesKittySequence(data, codepoint, MODIFIERS.ctrl);
1115
+ return (
1116
+ matchesKittySequence(data, codepoint, MODIFIERS.ctrl) ||
1117
+ matchesModifyOtherKeys(data, codepoint, MODIFIERS.ctrl)
1118
+ );
1112
1119
  }
1113
1120
 
1114
1121
  if (ctrl && shift && !alt) {
1115
- return matchesKittySequence(data, codepoint, MODIFIERS.shift + MODIFIERS.ctrl);
1122
+ return (
1123
+ matchesKittySequence(data, codepoint, MODIFIERS.shift + MODIFIERS.ctrl) ||
1124
+ matchesModifyOtherKeys(data, codepoint, MODIFIERS.shift + MODIFIERS.ctrl)
1125
+ );
1116
1126
  }
1117
1127
 
1118
1128
  if (shift && !ctrl && !alt) {
1119
1129
  // Legacy: shift+letter produces uppercase
1120
1130
  if (data === key.toUpperCase()) return true;
1121
- return matchesKittySequence(data, codepoint, MODIFIERS.shift);
1131
+ return (
1132
+ matchesKittySequence(data, codepoint, MODIFIERS.shift) ||
1133
+ matchesModifyOtherKeys(data, codepoint, MODIFIERS.shift)
1134
+ );
1122
1135
  }
1123
1136
 
1124
1137
  if (modifier !== 0) {
1125
- return matchesKittySequence(data, codepoint, modifier);
1138
+ return (
1139
+ matchesKittySequence(data, codepoint, modifier) ||
1140
+ matchesModifyOtherKeys(data, codepoint, modifier)
1141
+ );
1126
1142
  }
1127
1143
 
1128
1144
  // Check both raw char and Kitty sequence (needed for release events)
@@ -1251,3 +1267,65 @@ export function parseKey(data: string): string | undefined {
1251
1267
 
1252
1268
  return undefined;
1253
1269
  }
1270
+
1271
+ // =============================================================================
1272
+ // Mouse Event Parsing
1273
+ // =============================================================================
1274
+
1275
+ /**
1276
+ * Parsed mouse event from SGR extended format.
1277
+ * SGR format: \x1b[<button;column;row[Mm]
1278
+ * M = press, m = release
1279
+ */
1280
+ export interface MouseEvent {
1281
+ /** Event type */
1282
+ type: "scroll-up" | "scroll-down" | "press" | "release" | "drag";
1283
+ /** 0=left, 1=middle, 2=right */
1284
+ button: number;
1285
+ /** 1-indexed column */
1286
+ x: number;
1287
+ /** 1-indexed row */
1288
+ y: number;
1289
+ }
1290
+
1291
+ /** SGR extended mouse format: \x1b[<button;x;y[Mm] */
1292
+ const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/;
1293
+
1294
+ /**
1295
+ * Parse an SGR mouse event from raw terminal input.
1296
+ *
1297
+ * @param data - Raw terminal input
1298
+ * @returns Parsed mouse event, or null if not a mouse sequence
1299
+ */
1300
+ export function parseMouseEvent(data: string): MouseEvent | null {
1301
+ const match = data.match(SGR_MOUSE_RE);
1302
+ if (!match) return null;
1303
+
1304
+ const code = parseInt(match[1]!, 10);
1305
+ const x = parseInt(match[2]!, 10);
1306
+ const y = parseInt(match[3]!, 10);
1307
+ const isRelease = match[4] === "m";
1308
+
1309
+ // Scroll wheel: codes 64 (up) and 65 (down)
1310
+ if (code === 64) return { type: "scroll-up", button: 0, x, y };
1311
+ if (code === 65) return { type: "scroll-down", button: 0, x, y };
1312
+
1313
+ // Button number is in the low 2 bits
1314
+ const button = code & 0x03;
1315
+
1316
+ // Bit 5 (32) = motion/drag
1317
+ if (code & 32) return { type: "drag", button, x, y };
1318
+
1319
+ return { type: isRelease ? "release" : "press", button, x, y };
1320
+ }
1321
+
1322
+ /**
1323
+ * Fast check: is this data an SGR mouse event?
1324
+ * Avoids regex for non-mouse input.
1325
+ *
1326
+ * @param data - Raw terminal input
1327
+ * @returns true if the input is an SGR mouse sequence
1328
+ */
1329
+ export function isMouseEvent(data: string): boolean {
1330
+ return data.length >= 9 && data.startsWith("\x1b[<");
1331
+ }
@@ -30,6 +30,9 @@ export interface Terminal {
30
30
  // Whether Kitty keyboard protocol is active
31
31
  get kittyProtocolActive(): boolean;
32
32
 
33
+ // Whether running inside tmux (using modifyOtherKeys instead of Kitty protocol)
34
+ get isTmux(): boolean;
35
+
33
36
  // Cursor positioning (relative to current position)
34
37
  moveBy(lines: number): void; // Move cursor up (negative) or down (positive) by N lines
35
38
 
@@ -42,6 +45,10 @@ export interface Terminal {
42
45
  clearFromCursor(): void; // Clear from cursor to end of screen
43
46
  clearScreen(): void; // Clear entire screen and move cursor to (0,0)
44
47
 
48
+ // Mouse reporting
49
+ enableMouse(): void; // Enable SGR mouse tracking (scroll, click)
50
+ disableMouse(): void; // Disable mouse tracking
51
+
45
52
  // Screen buffer mode
46
53
  enterAlternateScreen(): void; // Switch to alternate screen buffer (no scrollback)
47
54
  leaveAlternateScreen(): void; // Restore normal screen buffer and scrollback
@@ -67,11 +74,16 @@ export class ProcessTerminal implements Terminal {
67
74
  private stdinDataHandler?: (data: string) => void;
68
75
  private writeLogPath = process.env.PI_TUI_WRITE_LOG || "";
69
76
  private alternateScreenActive = false;
77
+ private mouseActive = false;
70
78
 
71
79
  get kittyProtocolActive(): boolean {
72
80
  return this._kittyProtocolActive;
73
81
  }
74
82
 
83
+ get isTmux(): boolean {
84
+ return !!process.env.TMUX;
85
+ }
86
+
75
87
  start(onInput: (data: string) => void, onResize: () => void): void {
76
88
  this.inputHandler = onInput;
77
89
  this.resizeHandler = onResize;
@@ -97,10 +109,24 @@ export class ProcessTerminal implements Terminal {
97
109
  process.kill(process.pid, "SIGWINCH");
98
110
  }
99
111
 
100
- // Query and enable Kitty keyboard protocol
101
- // The query handler intercepts input temporarily, then installs the user's handler
102
- // See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
103
- this.queryAndEnableKittyProtocol();
112
+ // Mouse tracking (enableMouse/disableMouse) is available but NOT
113
+ // enabled anywhere yet. The TUI has no scroll viewport content
114
+ // off-screen is only reachable via terminal scrollback (or tmux
115
+ // copy-mode). Enabling mouse tracking steals scroll events without
116
+ // providing scroll handling, making things strictly worse. Enable
117
+ // only after implementing a scroll viewport in the TUI.
118
+
119
+ // Enable keyboard protocol for modified key detection.
120
+ // tmux doesn't support the Kitty keyboard protocol but does support xterm's
121
+ // modifyOtherKeys. Detect tmux and use the appropriate protocol.
122
+ if (process.env.TMUX) {
123
+ this.setupTmuxInput();
124
+ } else {
125
+ // Query and enable Kitty keyboard protocol
126
+ // The query handler intercepts input temporarily, then installs the user's handler
127
+ // See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
128
+ this.queryAndEnableKittyProtocol();
129
+ }
104
130
  }
105
131
 
106
132
  /**
@@ -154,6 +180,22 @@ export class ProcessTerminal implements Terminal {
154
180
  };
155
181
  }
156
182
 
183
+ /**
184
+ * Set up stdin handling for tmux without Kitty keyboard protocol.
185
+ *
186
+ * tmux doesn't support the Kitty keyboard protocol. Escape and Ctrl+C
187
+ * arrive as raw bytes (\x1b and \x03) which the key matching handles natively.
188
+ *
189
+ * For Shift+Enter, tmux needs `extended-keys on` and `extended-keys-format csi-u`
190
+ * in tmux.conf. With that config, we request modifyOtherKeys mode 1 so tmux
191
+ * encodes modified keys (Shift+Enter → CSI 13;2 u) while leaving standard
192
+ * keys (Escape, Ctrl+C, regular typing) as raw bytes.
193
+ */
194
+ private setupTmuxInput(): void {
195
+ this.setupStdinBuffer();
196
+ process.stdin.on("data", this.stdinDataHandler!);
197
+ }
198
+
157
199
  /**
158
200
  * Query terminal for Kitty keyboard protocol support and enable if available.
159
201
  *
@@ -209,6 +251,9 @@ export class ProcessTerminal implements Terminal {
209
251
  this.leaveAlternateScreen();
210
252
  }
211
253
 
254
+ // Disable mouse tracking
255
+ this.disableMouse();
256
+
212
257
  // Disable bracketed paste mode
213
258
  process.stdout.write("\x1b[?2004l");
214
259
 
@@ -297,6 +342,26 @@ export class ProcessTerminal implements Terminal {
297
342
  process.stdout.write("\x1b[2J\x1b[H"); // Clear screen and move to home (1,1)
298
343
  }
299
344
 
345
+ /**
346
+ * Enable SGR mouse tracking.
347
+ * Mode 1000 = button press/release (includes scroll wheel).
348
+ * Mode 1006 = SGR extended format (avoids 223-column limit).
349
+ */
350
+ enableMouse(): void {
351
+ if (this.mouseActive) return;
352
+ process.stdout.write("\x1b[?1000h\x1b[?1006h");
353
+ this.mouseActive = true;
354
+ }
355
+
356
+ /**
357
+ * Disable mouse tracking and restore default terminal mouse handling.
358
+ */
359
+ disableMouse(): void {
360
+ if (!this.mouseActive) return;
361
+ process.stdout.write("\x1b[?1006l\x1b[?1000l");
362
+ this.mouseActive = false;
363
+ }
364
+
300
365
  /**
301
366
  * Switch to alternate screen buffer and clear it.
302
367
  * @returns void
@@ -5,7 +5,13 @@
5
5
  import * as fs from "node:fs";
6
6
  import * as os from "node:os";
7
7
  import * as path from "node:path";
8
- import { isKeyRelease, matchesKey } from "./keys.js";
8
+ import {
9
+ isKeyRelease,
10
+ isMouseEvent,
11
+ type MouseEvent,
12
+ matchesKey,
13
+ parseMouseEvent,
14
+ } from "./keys.js";
9
15
  import type { Terminal } from "./terminal.js";
10
16
  import { getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js";
11
17
  import {
@@ -209,6 +215,13 @@ export class TUI extends Container {
209
215
 
210
216
  /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
211
217
  public onDebug?: () => void;
218
+ /**
219
+ * Callback for mouse events. Called when a mouse event is received.
220
+ * Scroll events are the primary use case (scroll-up, scroll-down).
221
+ * Return value is ignored — mouse events are always consumed and never
222
+ * forwarded to focused components.
223
+ */
224
+ public onMouse?: (event: MouseEvent) => void;
212
225
  private renderRequested = false;
213
226
  private pendingRenderHandle?: ReturnType<typeof setTimeout>;
214
227
  private cursorRow = 0; // Logical cursor row (end of rendered content)
@@ -220,6 +233,7 @@ export class TUI extends Container {
220
233
  private maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
221
234
  private previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
222
235
  private fullRedrawCount = 0;
236
+ private rollingShrinkPeak = 0; // Recent peak line count for gradual shrink detection
223
237
  private stopped = false;
224
238
  private pendingScrollbackClear = false; // Clear scrollback on next full render (session breaks)
225
239
 
@@ -269,6 +283,19 @@ export class TUI extends Container {
269
283
  this.clearOnShrink = enabled;
270
284
  }
271
285
 
286
+ /**
287
+ * Reset the startup grace period timer, suppressing screen-clearing full
288
+ * redraws for another {@link STARTUP_GRACE_MS} milliseconds.
289
+ *
290
+ * Call this at the start of a session switch so the chatContainer.clear()
291
+ * → renderInitialMessages() transition doesn't cause visible flicker.
292
+ *
293
+ * @returns {void}
294
+ */
295
+ resetRenderGrace(): void {
296
+ this.startedAtMs = Date.now();
297
+ }
298
+
272
299
  /**
273
300
  * Request that the next full render clears the terminal scrollback buffer.
274
301
  *
@@ -389,8 +416,25 @@ export class TUI extends Container {
389
416
  for (const overlay of this.overlayStack) overlay.component.invalidate?.();
390
417
  }
391
418
 
419
+ /**
420
+ * Timestamp when `start()` was called.
421
+ * Used by startup grace period to suppress screen-clearing full redraws.
422
+ */
423
+ private startedAtMs = 0;
424
+
425
+ /**
426
+ * Duration (ms) after `start()` during which shrink-triggered full redraws
427
+ * use a gentler line-by-line overwrite instead of screen clear.
428
+ *
429
+ * This prevents the visual flicker that occurs when session resume causes
430
+ * rapid content height changes (extension hooks, widget adds/removes) before
431
+ * the full message history is rendered.
432
+ */
433
+ private static readonly STARTUP_GRACE_MS = 3000;
434
+
392
435
  start(): void {
393
436
  this.stopped = false;
437
+ this.startedAtMs = Date.now();
394
438
  this.terminal.start(
395
439
  (data) => this.handleInput(data),
396
440
  () => this.requestRender()
@@ -405,6 +449,12 @@ export class TUI extends Container {
405
449
  if (!getCapabilities().images) {
406
450
  return;
407
451
  }
452
+ // Skip cell size query inside tmux — tmux doesn't forward CSI 16 t responses,
453
+ // so cellSizeQueryPending would stay true and parseCellSizeResponse would eat
454
+ // bare \x1b (Escape key) as a "partial response", breaking Escape handling.
455
+ if (process.env.TMUX) {
456
+ return;
457
+ }
408
458
  // Query terminal for cell size in pixels: CSI 16 t
409
459
  // Response format: CSI 6 ; height ; width t
410
460
  this.cellSizeQueryPending = true;
@@ -434,6 +484,45 @@ export class TUI extends Container {
434
484
  this.terminal.stop();
435
485
  }
436
486
 
487
+ /** When >0, scheduled renders are deferred until the batch completes. */
488
+ private renderBatchDepth = 0;
489
+
490
+ /** Whether a render was requested while batching was active. */
491
+ private renderDeferredDuringBatch = false;
492
+
493
+ /** Whether a forced render was requested while batching was active. */
494
+ private renderForceDeferredDuringBatch = false;
495
+
496
+ /**
497
+ * Begin a render batch — all `requestRender()` calls are coalesced and
498
+ * deferred until the matching `endRenderBatch()`. Nestable.
499
+ *
500
+ * Use to prevent intermediate renders (and the screen clears they cause)
501
+ * during multi-step UI mutations such as session resume.
502
+ *
503
+ * @returns {void}
504
+ */
505
+ beginRenderBatch(): void {
506
+ this.renderBatchDepth++;
507
+ }
508
+
509
+ /**
510
+ * End a render batch. When the outermost batch ends, a single render is
511
+ * scheduled if any were deferred.
512
+ *
513
+ * @returns {void}
514
+ */
515
+ endRenderBatch(): void {
516
+ if (this.renderBatchDepth <= 0) return;
517
+ this.renderBatchDepth--;
518
+ if (this.renderBatchDepth === 0 && this.renderDeferredDuringBatch) {
519
+ const wasForce = this.renderForceDeferredDuringBatch;
520
+ this.renderDeferredDuringBatch = false;
521
+ this.renderForceDeferredDuringBatch = false;
522
+ this.requestRender(wasForce);
523
+ }
524
+ }
525
+
437
526
  requestRender(force = false): void {
438
527
  if (force) {
439
528
  this.previousLines = [];
@@ -442,6 +531,12 @@ export class TUI extends Container {
442
531
  this.hardwareCursorRow = 0;
443
532
  this.maxLinesRendered = 0;
444
533
  this.previousViewportTop = 0;
534
+ this.rollingShrinkPeak = 0;
535
+ }
536
+ if (this.renderBatchDepth > 0) {
537
+ this.renderDeferredDuringBatch = true;
538
+ if (force) this.renderForceDeferredDuringBatch = true;
539
+ return;
445
540
  }
446
541
  if (this.renderRequested) return;
447
542
  this.scheduleRender();
@@ -511,6 +606,16 @@ export class TUI extends Container {
511
606
  data = filtered;
512
607
  }
513
608
 
609
+ // Mouse events — intercept before any key handling.
610
+ // Always consumed: mouse sequences must never reach components as text.
611
+ if (isMouseEvent(data)) {
612
+ const event = parseMouseEvent(data);
613
+ if (event && this.onMouse) {
614
+ this.onMouse(event);
615
+ }
616
+ return;
617
+ }
618
+
514
619
  // Global debug key handler (Shift+Ctrl+D)
515
620
  if (matchesKey(data, "shift+ctrl+d") && this.onDebug) {
516
621
  this.onDebug();
@@ -556,6 +661,19 @@ export class TUI extends Container {
556
661
  if (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) {
557
662
  return;
558
663
  }
664
+ if (process.env.TALLOW_KEY_DEBUG && (data === "\x1b" || data === "\x03")) {
665
+ const escMatch = matchesKey(data, "escape");
666
+ const ctrlcMatch = matchesKey(data, "ctrl+c");
667
+ const comp = this.focusedComponent as unknown as {
668
+ onEscape?: () => void;
669
+ actionHandlers?: Map<string, unknown>;
670
+ };
671
+ const hasOnEscape = typeof comp.onEscape === "function";
672
+ const actionCount = comp.actionHandlers instanceof Map ? comp.actionHandlers.size : -1;
673
+ process.stderr.write(
674
+ `[key] ${data === "\x1b" ? "ESC" : "C-C"} matchEsc=${escMatch} matchCC=${ctrlcMatch} hasOnEscape=${hasOnEscape} actions=${actionCount}\n`
675
+ );
676
+ }
559
677
  this.focusedComponent.handleInput(data);
560
678
  this.requestRender();
561
679
  }
@@ -960,6 +1078,11 @@ export class TUI extends Container {
960
1078
  // Width changed - need full re-render (line wrapping changes)
961
1079
  const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
962
1080
 
1081
+ // Whether we are within the startup grace period where screen-clearing
1082
+ // full redraws are softened to prevent flicker during session resume.
1083
+ const inStartupGrace =
1084
+ this.startedAtMs > 0 && Date.now() - this.startedAtMs < TUI.STARTUP_GRACE_MS;
1085
+
963
1086
  // Helper to clear viewport (and optionally scrollback) and render all new lines
964
1087
  const fullRender = (clear: boolean): void => {
965
1088
  this.fullRedrawCount += 1;
@@ -985,6 +1108,45 @@ export class TUI extends Container {
985
1108
  this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
986
1109
  }
987
1110
  this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
1111
+ this.rollingShrinkPeak = newLines.length;
1112
+ this.positionHardwareCursor(cursorPos, newLines.length);
1113
+ this.previousLines = newLines;
1114
+ this.previousWidth = width;
1115
+ };
1116
+
1117
+ /**
1118
+ * Gentle full redraw: home cursor + overwrite each line + clear below.
1119
+ *
1120
+ * Used during the startup grace period instead of fullRender(true) for
1121
+ * shrink-triggered redraws. Avoids the visible blank frame caused by
1122
+ * `\x1b[2J` (clear screen), which makes messages appear to flash in and
1123
+ * out when session resume triggers rapid content height changes.
1124
+ *
1125
+ * Unlike fullRender(true), this never clears the screen — it writes each
1126
+ * line with a preceding `\x1b[2K` (clear line) so stale content is
1127
+ * overwritten without a blank frame. Lines below the new content are
1128
+ * individually erased.
1129
+ */
1130
+ const gentleFullRender = (): void => {
1131
+ this.fullRedrawCount += 1;
1132
+ let buffer = "\x1b[?2026h\x1b[H"; // Begin synchronized output + home cursor
1133
+ for (let i = 0; i < newLines.length; i++) {
1134
+ buffer += "\x1b[2K"; // Clear current line
1135
+ buffer += newLines[i];
1136
+ if (i < newLines.length - 1) buffer += "\r\n";
1137
+ }
1138
+ // Erase lines that were previously rendered but are no longer needed
1139
+ const staleLines = Math.max(0, this.maxLinesRendered - newLines.length);
1140
+ for (let i = 0; i < staleLines; i++) {
1141
+ buffer += "\r\n\x1b[2K";
1142
+ }
1143
+ buffer += "\x1b[?2026l"; // End synchronized output
1144
+ this.terminal.write(buffer);
1145
+ this.cursorRow = Math.max(0, newLines.length + staleLines - 1);
1146
+ this.hardwareCursorRow = this.cursorRow;
1147
+ this.maxLinesRendered = newLines.length;
1148
+ this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
1149
+ this.rollingShrinkPeak = newLines.length;
988
1150
  this.positionHardwareCursor(cursorPos, newLines.length);
989
1151
  this.previousLines = newLines;
990
1152
  this.previousWidth = width;
@@ -1021,7 +1183,11 @@ export class TUI extends Container {
1021
1183
  this.overlayStack.length === 0
1022
1184
  ) {
1023
1185
  logRedraw(`clearOnShrink (maxLinesRendered=${this.maxLinesRendered})`);
1024
- fullRender(true);
1186
+ if (inStartupGrace) {
1187
+ gentleFullRender();
1188
+ } else {
1189
+ fullRender(true);
1190
+ }
1025
1191
  return;
1026
1192
  }
1027
1193
 
@@ -1032,7 +1198,30 @@ export class TUI extends Container {
1032
1198
  const shrinkDelta = this.previousLines.length - newLines.length;
1033
1199
  if (shrinkDelta > 5 && this.overlayStack.length === 0) {
1034
1200
  logRedraw(`large shrink (${shrinkDelta} lines)`);
1035
- fullRender(true);
1201
+ if (inStartupGrace) {
1202
+ gentleFullRender();
1203
+ } else {
1204
+ fullRender(true);
1205
+ }
1206
+ return;
1207
+ }
1208
+
1209
+ // Rolling shrink detection: catches gradual shrinks where each individual
1210
+ // frame-to-frame delta is ≤5 lines (below the large-shrink threshold) but the
1211
+ // accumulated shrink from a recent peak exceeds it. This happens when
1212
+ // pollStates.clear() collapses tool-result anchors across multiple render
1213
+ // cycles while animations (loader, widget spinners) keep triggering renders.
1214
+ if (newLines.length >= this.rollingShrinkPeak) {
1215
+ this.rollingShrinkPeak = newLines.length;
1216
+ } else if (this.overlayStack.length === 0 && this.rollingShrinkPeak - newLines.length > 5) {
1217
+ logRedraw(
1218
+ `rolling shrink (peak=${this.rollingShrinkPeak}, now=${newLines.length}, delta=${this.rollingShrinkPeak - newLines.length})`
1219
+ );
1220
+ if (inStartupGrace) {
1221
+ gentleFullRender();
1222
+ } else {
1223
+ fullRender(true);
1224
+ }
1036
1225
  return;
1037
1226
  }
1038
1227
 
@@ -1107,7 +1296,11 @@ export class TUI extends Container {
1107
1296
  const extraLines = this.previousLines.length - newLines.length;
1108
1297
  if (extraLines > height) {
1109
1298
  logRedraw(`extraLines > height (${extraLines} > ${height})`);
1110
- fullRender(true);
1299
+ if (inStartupGrace) {
1300
+ gentleFullRender();
1301
+ } else {
1302
+ fullRender(true);
1303
+ }
1111
1304
  return;
1112
1305
  }
1113
1306
  if (extraLines > 0) {
@@ -1135,7 +1328,11 @@ export class TUI extends Container {
1135
1328
  // If first changed line is above the current viewport basis, partial redraw is unsafe.
1136
1329
  if (firstChanged < prevViewportTop) {
1137
1330
  logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
1138
- fullRender(true);
1331
+ if (inStartupGrace) {
1332
+ gentleFullRender();
1333
+ } else {
1334
+ fullRender(true);
1335
+ }
1139
1336
  return;
1140
1337
  }
1141
1338
 
@@ -1306,6 +1503,9 @@ export class TUI extends Container {
1306
1503
  this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
1307
1504
  }
1308
1505
  this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
1506
+ // Update rolling peak for gradual shrink detection (partial path only —
1507
+ // fullRender paths reset it inside the fullRender closure).
1508
+ this.rollingShrinkPeak = Math.max(this.rollingShrinkPeak, newLines.length);
1309
1509
 
1310
1510
  // Position hardware cursor for IME
1311
1511
  this.positionHardwareCursor(cursorPos, newLines.length);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dungle-scrubs/tallow",
3
- "version": "0.8.27",
3
+ "version": "0.9.0",
4
4
  "description": "An opinionated coding agent. Built on pi.",
5
5
  "piConfig": {
6
6
  "name": "tallow",
@@ -74,25 +74,25 @@
74
74
  },
75
75
  "dependencies": {
76
76
  "@clack/prompts": "^1.1.0",
77
- "@dungle-scrubs/synapse": "0.1.6",
78
- "@mariozechner/pi-coding-agent": "^0.60.0",
79
- "@mariozechner/pi-tui": "^0.60.0",
77
+ "@dungle-scrubs/synapse": "0.1.8",
78
+ "@mariozechner/pi-coding-agent": "^0.64.0",
79
+ "@mariozechner/pi-tui": "^0.62.0",
80
80
  "@opentelemetry/api": "^1.9.0",
81
81
  "@sinclair/typebox": "0.34.48",
82
82
  "ai": "^6.0.116",
83
83
  "commander": "^14.0.3",
84
84
  "unpdf": "^1.4.0",
85
- "vscode-jsonrpc": "8.2.1"
85
+ "vscode-jsonrpc": "8.2.1",
86
+ "vscode-languageserver-protocol": "3.17.5"
86
87
  },
87
88
  "devDependencies": {
88
89
  "@biomejs/biome": "2.4.2",
89
- "@mariozechner/pi-agent-core": "^0.60.0",
90
- "@mariozechner/pi-ai": "^0.60.0",
90
+ "@mariozechner/pi-agent-core": "^0.64.0",
91
+ "@mariozechner/pi-ai": "^0.64.0",
91
92
  "@types/node": "25.2.3",
92
93
  "husky": "^9.1.7",
93
94
  "lint-staged": "^16.4.0",
94
- "typescript": "^5.9.3",
95
- "vscode-languageserver-protocol": "3.17.5"
95
+ "typescript": "^5.9.3"
96
96
  },
97
97
  "engines": {
98
98
  "node": ">=22"
@@ -0,0 +1,7 @@
1
+ import { resolveRuntimeModuleUrl } from "./resolve-module.js";
2
+
3
+ const mod = (await import(
4
+ resolveRuntimeModuleUrl("config.js")
5
+ )) as typeof import("../src/config.js");
6
+
7
+ export const TALLOW_VERSION = mod.TALLOW_VERSION;
@@ -0,0 +1,7 @@
1
+ import { resolveRuntimeModuleUrl } from "./resolve-module.js";
2
+
3
+ const mod = (await import(
4
+ resolveRuntimeModuleUrl("model-metadata-overrides.js")
5
+ )) as typeof import("../src/model-metadata-overrides.js");
6
+
7
+ export const applyKnownModelMetadataOverrides = mod.applyKnownModelMetadataOverrides;
@@ -429,11 +429,6 @@
429
429
  "default": ["◐", "◓", "◑", "◒"],
430
430
  "minItems": 1
431
431
  },
432
- "plan_mode": {
433
- "type": "string",
434
- "description": "Plan mode indicator (default: '⏸').",
435
- "default": "⏸"
436
- },
437
432
  "task_list": {
438
433
  "type": "string",
439
434
  "description": "Task list indicator (default: '📋').",