jeo-code 0.6.30 → 0.6.31

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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  The README mirrors the latest 5 entries — regenerate with `bun run changelog:sync`.
8
8
 
9
+ ## [0.6.31] - 2026-06-19
10
+ _Live "Thinking" indicator for signature-only reasoning models (Anthropic opus-4-7/4-8), a live color cue when a `/command` or `$skill` trigger is recognized in the prompt, and a rich gjc-style `/resume` session picker — plus a fresh `jeo --tmux` no-leak re-verification._
11
+
12
+ ### Added
13
+ - **The prompt box now recolors the `/command` / `$skill` trigger token live as you type it.** While typing an invocation, the active trigger token (anywhere on the line, mention-style via `activeTriggerToken`) is repainted inside the input box so the user can SEE the trigger was recognized: a valid, matchable invocation turns neon green (`#39ff14`), while a typo with no match turns pink (`#ff6b81`) — a visual heads-up that it will be sent as plain text. Wired through a new `InputBoxOptions.highlight` ({start,end,paint}, code-point offsets over `Array.from(line)`) into both the idle prompt (`launch.ts` `previewLines`) and the mid-turn live box (`app.ts` `setLivePromptHighlight`, reset at each new turn). Scroll ellipses now use ANSI-safe `truncateToWidth` so a painted token never gets sliced mid-escape.
14
+ - **Rich `/resume` session picker (gjc parity).** A new `src/tui/components/session-picker.ts` renders a search/filter line, a scrolling window of multi-line entries (title + dimmed first-message preview + a `relative-time · size · N msgs` metadata line), a position indicator, and Del-to-delete / Enter-to-resume / Esc-to-cancel hints. `SessionSummary` now carries `sizeBytes` for the metadata line.
15
+
16
+ ### Fixed
17
+ - **Signature-only reasoning models now show a live Thinking block while the model thinks.** Models that reason internally and stream a `signature` but NO `thinking_delta` text (claude-opus-4-7/4-8) opened a thinking block that produced zero visible deltas, so the TUI's dimmed live "Thinking" trace never appeared — the response wait read as a frozen "calling model …". The Anthropic stream adapter now fires a new display-only `onReasoningStart` signal the instant a `thinking` / `redacted_thinking` block opens, and the TUI renders a live `Thinking · Ns` block with a `(thinking…)` placeholder that is replaced the moment any real thought or answer text streams. Replay/artifact capture is unchanged.
18
+
19
+ ### Verified
20
+ - **`jeo --tmux` has no bun memory leak and stays responsive.** A real `--tmux` session flooded with ~30,000 SGR mouse-report sequences via `tmux send-keys` plateaus in RSS (147 → 246 MB asymptotically: +83 / +12 / +3 / +0.2 / +0.4 MB per 6k-report round → no per-event linear growth) and stays responsive afterward (`/model` preview renders in 14 ms with the trigger highlight intact). The mouse-report swallow guard drops the reports instead of buffering/echoing them.
21
+ - **Full suite green:** `bun run typecheck` clean and `bun test` 1703 pass / 0 fail across 211 files (includes the new `test/input-box.test.ts`, `test/tui-app.test.ts`, and `test/session-picker.test.ts` highlight/picker coverage).
22
+
9
23
  ## [0.6.30] - 2026-06-19
10
24
  _gjc-style intermediate-judgment guard classification extracted from the engine loop, plus a re-verification that `jeo --tmux` does not leak bun memory or slow down._
11
25
 
package/README.ja.md CHANGED
@@ -200,11 +200,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
200
200
  ## 変更履歴 (Changelog)
201
201
 
202
202
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
203
+ - **[0.6.31]** (2026-06-19) — Live "Thinking" indicator for signature-only reasoning models (Anthropic opus-4-7/4-8), a live color cue when a `/command` or `$skill` trigger is recognized in the prompt, and a rich gjc-style `/resume` session picker — plus a fresh `jeo --tmux` no-leak re-verification.
203
204
  - **[0.6.30]** (2026-06-19) — gjc-style intermediate-judgment guard classification extracted from the engine loop, plus a re-verification that `jeo --tmux` does not leak bun memory or slow down.
204
205
  - **[0.6.29]** (2026-06-19) — Signature-only thinking-block replay (Anthropic opus-4-7/4-8), plus a tmux mouse-flood memory guard confirming `jeo --tmux` does not leak.
205
206
  - **[0.6.28]** (2026-06-19) — Signed thinking-block replay: native reasoning is now sent BACK to providers across steps/turns, restoring multi-step reasoning continuity (gajae parity).
206
207
  - **[0.6.27]** (2026-06-19) — Ponytail pass on the reasoning-tier mapper, plus a real-tmux verification of `jeo --tmux`.
207
- - **[0.6.26]** (2026-06-19) — The forge emblem is redrawn again as the mascot crayfish, foregrounding its signature pincer claws (집게).
208
208
 
209
209
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
210
210
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -200,11 +200,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
200
200
  ## 변경 이력 (Changelog)
201
201
 
202
202
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
203
+ - **[0.6.31]** (2026-06-19) — Live "Thinking" indicator for signature-only reasoning models (Anthropic opus-4-7/4-8), a live color cue when a `/command` or `$skill` trigger is recognized in the prompt, and a rich gjc-style `/resume` session picker — plus a fresh `jeo --tmux` no-leak re-verification.
203
204
  - **[0.6.30]** (2026-06-19) — gjc-style intermediate-judgment guard classification extracted from the engine loop, plus a re-verification that `jeo --tmux` does not leak bun memory or slow down.
204
205
  - **[0.6.29]** (2026-06-19) — Signature-only thinking-block replay (Anthropic opus-4-7/4-8), plus a tmux mouse-flood memory guard confirming `jeo --tmux` does not leak.
205
206
  - **[0.6.28]** (2026-06-19) — Signed thinking-block replay: native reasoning is now sent BACK to providers across steps/turns, restoring multi-step reasoning continuity (gajae parity).
206
207
  - **[0.6.27]** (2026-06-19) — Ponytail pass on the reasoning-tier mapper, plus a real-tmux verification of `jeo --tmux`.
207
- - **[0.6.26]** (2026-06-19) — The forge emblem is redrawn again as the mascot crayfish, foregrounding its signature pincer claws (집게).
208
208
 
209
209
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
210
210
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -200,11 +200,11 @@ Required npm token permissions (repository secret `NPM_TOKEN`):
200
200
  ## Changelog
201
201
 
202
202
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
203
+ - **[0.6.31]** (2026-06-19) — Live "Thinking" indicator for signature-only reasoning models (Anthropic opus-4-7/4-8), a live color cue when a `/command` or `$skill` trigger is recognized in the prompt, and a rich gjc-style `/resume` session picker — plus a fresh `jeo --tmux` no-leak re-verification.
203
204
  - **[0.6.30]** (2026-06-19) — gjc-style intermediate-judgment guard classification extracted from the engine loop, plus a re-verification that `jeo --tmux` does not leak bun memory or slow down.
204
205
  - **[0.6.29]** (2026-06-19) — Signature-only thinking-block replay (Anthropic opus-4-7/4-8), plus a tmux mouse-flood memory guard confirming `jeo --tmux` does not leak.
205
206
  - **[0.6.28]** (2026-06-19) — Signed thinking-block replay: native reasoning is now sent BACK to providers across steps/turns, restoring multi-step reasoning continuity (gajae parity).
206
207
  - **[0.6.27]** (2026-06-19) — Ponytail pass on the reasoning-tier mapper, plus a real-tmux verification of `jeo --tmux`.
207
- - **[0.6.26]** (2026-06-19) — The forge emblem is redrawn again as the mascot crayfish, foregrounding its signature pincer claws (집게).
208
208
 
209
209
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
210
210
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -200,11 +200,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
200
200
  ## 更新日志 (Changelog)
201
201
 
202
202
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
203
+ - **[0.6.31]** (2026-06-19) — Live "Thinking" indicator for signature-only reasoning models (Anthropic opus-4-7/4-8), a live color cue when a `/command` or `$skill` trigger is recognized in the prompt, and a rich gjc-style `/resume` session picker — plus a fresh `jeo --tmux` no-leak re-verification.
203
204
  - **[0.6.30]** (2026-06-19) — gjc-style intermediate-judgment guard classification extracted from the engine loop, plus a re-verification that `jeo --tmux` does not leak bun memory or slow down.
204
205
  - **[0.6.29]** (2026-06-19) — Signature-only thinking-block replay (Anthropic opus-4-7/4-8), plus a tmux mouse-flood memory guard confirming `jeo --tmux` does not leak.
205
206
  - **[0.6.28]** (2026-06-19) — Signed thinking-block replay: native reasoning is now sent BACK to providers across steps/turns, restoring multi-step reasoning continuity (gajae parity).
206
207
  - **[0.6.27]** (2026-06-19) — Ponytail pass on the reasoning-tier mapper, plus a real-tmux verification of `jeo --tmux`.
207
- - **[0.6.26]** (2026-06-19) — The forge emblem is redrawn again as the mascot crayfish, foregrounding its signature pincer claws (집게).
208
208
 
209
209
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
210
210
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.6.30",
3
+ "version": "0.6.31",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -35,6 +35,7 @@ async function invokeCallLlm(history: Message[], options: {
35
35
  onRetry?: (attempt: number, err: unknown, delayMs: number) => void;
36
36
  onToken?: (delta: string) => void;
37
37
  onReasoning?: (delta: string) => void;
38
+ onReasoningStart?: () => void;
38
39
  onReasoningArtifact?: (artifact: import("../ai/types").ReasoningArtifact) => void;
39
40
  tools?: import("../ai/types").NativeToolSchema[];
40
41
  }): Promise<string> {
@@ -196,6 +197,10 @@ export interface AgentLoopEvents {
196
197
  /** Accumulated native reasoning/thinking text so far — drives a transient dimmed
197
198
  * "thinking" view. Only requested when a consumer (TUI) attaches. */
198
199
  onReasoningStream?(textSoFar: string): void;
200
+ /** Fired once when the model opens an extended-thinking block (before/without any
201
+ * thinking text). Lets the TUI show a live "thinking" indicator for signature-only
202
+ * reasoning models (opus-4-7/4-8) whose wait would otherwise look frozen. */
203
+ onReasoningStart?(): void;
199
204
  /** Each provider-native reasoning ARTIFACT as it is captured (signature / thoughtSignature /
200
205
  * reasoning item). Lets the final-reply path (launch.ts) persist artifacts for replay. */
201
206
  onReasoningArtifactStream?(artifact: import("../ai/types").ReasoningArtifact): void;
@@ -526,6 +531,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
526
531
  onUsage: u => { acc.inputTokens += u.inputTokens ?? 0; acc.outputTokens += u.outputTokens ?? 0; sawUsage = true; },
527
532
  onToken,
528
533
  onReasoning,
534
+ onReasoningStart: ev.onReasoningStart,
529
535
  onReasoningArtifact,
530
536
  // Make provider auto-retry visible: previously a rate-limited call sat in a
531
537
  // silent backoff wait, then surfaced "auto-retry was exhausted" with no trace
@@ -32,6 +32,8 @@ export interface SessionSummary {
32
32
  messageCount: number;
33
33
  preview: string;
34
34
  mtimeMs?: number;
35
+ /** Session file size in bytes (for the resume picker's metadata line). */
36
+ sizeBytes?: number;
35
37
  title?: string;
36
38
  }
37
39
 
@@ -288,6 +290,7 @@ export async function listSessions(cwd = process.cwd()): Promise<SessionSummary[
288
290
  messageCount,
289
291
  preview,
290
292
  mtimeMs: stat.mtimeMs,
293
+ sizeBytes: stat.size,
291
294
  title: header.title,
292
295
  });
293
296
  } catch {
@@ -353,8 +353,12 @@ export const anthropicAdapter: ProviderAdapter = {
353
353
  toolBlocks.set(evt.index, { name: evt.content_block.name ?? "", args: "" });
354
354
  } else if (evt.type === "content_block_start" && evt.content_block?.type === "thinking" && typeof evt.index === "number") {
355
355
  thinkBlocks.set(evt.index, { text: "" });
356
+ // Signal the thinking phase started so the UI shows a live "thinking" indicator
357
+ // even for signature-only models (opus-4-7/4-8) that stream NO thinking_delta text.
358
+ options.onReasoningStart?.();
356
359
  } else if (evt.type === "content_block_start" && evt.content_block?.type === "redacted_thinking" && evt.content_block.data) {
357
360
  // Redacted thinking carries opaque `data` directly (no deltas) — emit immediately.
361
+ options.onReasoningStart?.();
358
362
  options.onReasoningArtifact?.({ provider: "anthropic", model: options.model, redacted: evt.content_block.data });
359
363
  } else if (evt.type === "content_block_delta" && evt.delta?.type === "input_json_delta" && typeof evt.index === "number") {
360
364
  const b = toolBlocks.get(evt.index);
package/src/ai/types.ts CHANGED
@@ -116,6 +116,11 @@ export interface CallOptions {
116
116
  * answer text). Surfaced as a transient dimmed view; absent for models that emit no
117
117
  * thought text. */
118
118
  onReasoning?: (delta: string) => void;
119
+ /** Fired ONCE when the model opens an extended-thinking block, before (or without) any
120
+ * thinking-text deltas. Lets a UI show a live "thinking" indicator even for models
121
+ * (e.g. claude-opus-4-7/4-8) that reason internally and stream NO visible thought text,
122
+ * so the response wait does not look frozen. Display-only — carries no content. */
123
+ onReasoningStart?: () => void;
119
124
  /** Sink for provider-native reasoning ARTIFACTS captured during streaming (signature /
120
125
  * thoughtSignature / reasoning item id+encrypted). Separate from `onReasoning` (display
121
126
  * text) because these arrive on different SSE events and are opaque replay data. */
@@ -45,6 +45,7 @@ import { openaiCompatDef, SUBSCRIPTION_PROVIDER_NAMES } from "../ai/providers/op
45
45
 
46
46
  import { allSubagentRoles, getSubagentRole, resolveSubagentModel, resolveSubagentMaxSteps, resolveSubagentThinking, parseMaxSteps, withSubagentSetting, clearSubagentSetting } from "../agent/subagents";
47
47
  import { SelectList, renderSelectList, type SelectItem } from "../tui/components/select-list";
48
+ import { SessionPicker, renderSessionPicker } from "../tui/components/session-picker";
48
49
  import {
49
50
  formatModelLine,
50
51
  formatProviderPanel,
@@ -760,6 +761,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
760
761
  queueBusyClear?.();
761
762
  tui.setLivePromptInput("");
762
763
  tui.setLivePromptHint([]);
764
+ tui.setLivePromptHighlight(undefined);
763
765
  if (classifyMidTurnLine(line) === "command") {
764
766
  // Run it as a real COMMAND: queue it for immediate dispatch by the prompt
765
767
  // loop and abort the turn (the same controller Esc uses). The abort ends a
@@ -798,6 +800,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
798
800
  tui.setLivePromptHint(
799
801
  /^\s*[/$]/.test(draft) ? formatMidTurnHint(draft.trimStart(), completionContext(), 5) : [],
800
802
  );
803
+ tui.setLivePromptHighlight(triggerHighlight(expandSentinel(draft)));
801
804
  }
802
805
  },
803
806
  onAbortNotice: msg => {
@@ -1790,6 +1793,26 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1790
1793
  if (sessionId) { const hex = SESSION_BOX_ACCENTS[sessionBoxColorIdx]!; return { accent: hexPaint(hex), shadow: hexShadowPaint(hex) }; }
1791
1794
  return { accent: uiAccent, shadow: uiAccentShadow };
1792
1795
  };
1796
+ // Recolor the active `/command` or `$skill` trigger token INSIDE the input box so a
1797
+ // real invocation is visibly recognized as it is typed: neon green once the token
1798
+ // resolves to ≥1 command/skill, caution pink while it matches none (a likely typo
1799
+ // that would be sent as plain text). Offsets are code-point indices into the SAME
1800
+ // string the box renders, so multi-byte preceding text stays aligned with the box's
1801
+ // Array.from() char model. Returns undefined for colorless themes / no active trigger.
1802
+ const TRIGGER_HL_VALID = "#39ff14";
1803
+ const TRIGGER_HL_UNKNOWN = "#ff6b81";
1804
+ const triggerHighlight = (
1805
+ rendered: string,
1806
+ ): { start: number; end: number; paint: (s: string) => string } | undefined => {
1807
+ if (!uiTheme.color) return undefined;
1808
+ const trigger = activeTriggerToken(rendered);
1809
+ if (!trigger) return undefined;
1810
+ const start = Array.from(rendered.slice(0, trigger.start)).length;
1811
+ const end = start + Array.from(trigger.token).length;
1812
+ const valid = slashPreviewMatches(rendered, skillSlashDetails, resolvedSkills).length > 0;
1813
+ const hex = valid ? TRIGGER_HL_VALID : TRIGGER_HL_UNKNOWN;
1814
+ return { start, end, paint: (s: string) => chalk.hex(hex)(s) };
1815
+ };
1793
1816
  const refreshUiTheme = (): void => {
1794
1817
  uiTheme = resolveTheme(process.env);
1795
1818
  uiAccent = accentPaint(uiTheme);
@@ -1825,7 +1848,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1825
1848
  const rli = rl as unknown as { line?: string; cursor?: number };
1826
1849
  const caret = rli.line === line && typeof rli.cursor === "number" ? rli.cursor : line.length;
1827
1850
  const { accent: boxAccent, shadow: boxShadow } = boxAccents(line);
1828
- const frame = renderInputFrame(expandSentinel(line), {
1851
+ const rendered = expandSentinel(line);
1852
+ const frame = renderInputFrame(rendered, {
1829
1853
  // Full terminal width (cols is already columns - 1, leaving the last column free
1830
1854
  // so a full-width row never wraps). Matches the live-turn box, user/forge cards,
1831
1855
  // and the welcome banner — all share this cols-1 width so nothing jumps on the
@@ -1841,6 +1865,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1841
1865
  : undefined,
1842
1866
  maxBodyRows: Math.max(1, footerRows - 7),
1843
1867
  cursor: caret,
1868
+ highlight: triggerHighlight(rendered),
1844
1869
  });
1845
1870
  const input = frame.lines.map(l => truncateAnsi(l, cols));
1846
1871
  // jeo-ref layout: a blank spacer row between the status bar (row 0) and the
@@ -2930,26 +2955,71 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2930
2955
  if (arg) { await applyResume(arg); continue; }
2931
2956
  // No id → only sessions with a real conversation are resumable (every launch
2932
2957
  // creates an empty session; those are noise).
2933
- const sessions = (await listSessions(cwd)).filter(s => s.messageCount > 0);
2934
- if (sessions.length === 0) {
2958
+ let pool = (await listSessions(cwd)).filter(s => s.messageCount > 0);
2959
+ if (pool.length === 0) {
2935
2960
  console.log("(no saved sessions with history)");
2936
2961
  continue;
2937
2962
  }
2938
- // Interactive arrow-key picker on a TTY: ↑↓ to move, Enter to resume, Esc cancels.
2963
+ // Interactive gjc-style picker on a TTY: type to filter, ↑↓/PgUp/PgDn to
2964
+ // move, Enter resumes, Del deletes (press Del twice to confirm), Esc cancels.
2939
2965
  if (process.stdin.isTTY && process.stdout.isTTY) {
2940
- const items: SelectItem<string>[] = sessions.slice(0, 50).map(s => ({
2941
- value: s.id,
2942
- label: `${s.title ? `[${s.title}] ` : ""}${(s.preview || s.id).replace(/\s+/g, " ")}`.slice(0, 76) || s.id,
2943
- hint: `${s.messageCount} msgs${s.id === sessionId ? " · current" : ""}`,
2944
- }));
2945
- const picked = await pickFromOptions("Resume a session ↑↓ move · Enter resume · Esc cancel", items);
2946
- if (picked) await applyResume(picked);
2947
- else console.log("(resume cancelled)");
2966
+ // Loop so a delete refreshes the list and re-opens the picker in place.
2967
+ for (;;) {
2968
+ const picker = new SessionPicker(pool);
2969
+ let action: { kind: "resume" | "delete"; id: string } | undefined;
2970
+ let confirmDeleteId: string | undefined;
2971
+ await runSelectPicker(
2972
+ (cols, rows) => renderSessionPicker(picker, {
2973
+ title: "Resume a session",
2974
+ cols,
2975
+ rows: Math.max(8, rows),
2976
+ unicode: true,
2977
+ color: true,
2978
+ confirmDeleteId,
2979
+ }),
2980
+ (ch, key) => {
2981
+ if (key?.name === "up") { confirmDeleteId = undefined; picker.up(); return false; }
2982
+ if (key?.name === "down") { confirmDeleteId = undefined; picker.down(); return false; }
2983
+ if (key?.name === "pageup") { confirmDeleteId = undefined; picker.page(-1); return false; }
2984
+ if (key?.name === "pagedown") { confirmDeleteId = undefined; picker.page(1); return false; }
2985
+ if (key?.name === "escape" || (key?.ctrl && key.name === "c")) return true;
2986
+ if (key?.name === "delete") {
2987
+ const sel = picker.selected();
2988
+ if (!sel) return false;
2989
+ if (confirmDeleteId === sel.id) { action = { kind: "delete", id: sel.id }; return true; }
2990
+ confirmDeleteId = sel.id;
2991
+ return false;
2992
+ }
2993
+ if (key?.name === "return" || key?.name === "enter") {
2994
+ const sel = picker.selected();
2995
+ if (sel) { action = { kind: "resume", id: sel.id }; return true; }
2996
+ return false;
2997
+ }
2998
+ confirmDeleteId = undefined;
2999
+ if (key?.name === "backspace") { picker.backspace(); return false; }
3000
+ if (ch && ch >= " " && !key?.ctrl && !key?.meta) picker.typeChar(ch);
3001
+ return false;
3002
+ },
3003
+ );
3004
+ if (!action) { console.log("(resume cancelled)"); break; }
3005
+ if (action.kind === "resume") { await applyResume(action.id); break; }
3006
+ // Delete: drop the file, refresh the pool, and re-open the picker.
3007
+ const delId = action.id;
3008
+ try {
3009
+ const removed = await deleteSession(delId, cwd);
3010
+ console.log(removed ? `(deleted session ${delId})` : `(session ${delId} already gone)`);
3011
+ } catch (err) {
3012
+ console.log(`! delete failed: ${(err as Error).message}`);
3013
+ }
3014
+ if (delId === sessionId) await startFreshSession("dropped current session");
3015
+ pool = pool.filter(s => s.id !== delId);
3016
+ if (pool.length === 0) { console.log("(no saved sessions with history)"); break; }
3017
+ }
2948
3018
  continue;
2949
3019
  }
2950
3020
  // Non-TTY fallback: static list (resume with /session resume <id>).
2951
3021
  console.log("Saved sessions — resume with /session resume <id>:");
2952
- for (const s of sessions.slice(0, 15)) {
3022
+ for (const s of pool.slice(0, 15)) {
2953
3023
  const marker = s.id === sessionId ? "*" : " ";
2954
3024
  console.log(` ${marker}${s.id} (${s.messageCount} msgs) ${s.title ? `[${s.title}] ` : ""}${s.preview}`);
2955
3025
  }
package/src/tui/app.ts CHANGED
@@ -68,6 +68,9 @@ export interface AgentEventsLike {
68
68
  onUsage?(usage: { inputTokens: number; outputTokens: number }): void;
69
69
  onModelStream?(textSoFar: string): void;
70
70
  onReasoningStream?(textSoFar: string): void;
71
+ /** Fired once when the model opens an extended-thinking block — drives a live "thinking"
72
+ * placeholder for signature-only reasoning models (opus-4-7/4-8) that stream no thought text. */
73
+ onReasoningStart?(): void;
71
74
  /** Per-artifact native reasoning replay records (signature / thoughtSignature / reasoning
72
75
  * item). The TUI ignores these; launch.ts uses them to persist the final reply's artifacts. */
73
76
  onReasoningArtifactStream?(artifact: import("../ai/types").ReasoningArtifact): void;
@@ -247,6 +250,11 @@ export class LaunchTui {
247
250
  * streams, then persisted once into scrollback as a "Thinking" block on commit so the
248
251
  * model's reasoning stays visible above the answer (gjc "think → answer" parity). */
249
252
  private streamingThought = "";
253
+ /** True once the model opens an extended-thinking block this step. Signature-only
254
+ * reasoning models (opus-4-7/4-8) stream NO thinking text, so without this flag the
255
+ * live Thinking block never appears and the wait looks frozen. Drives a placeholder
256
+ * Thinking block until real thought/answer text streams. Reset each step / on commit. */
257
+ private thinkingActive = false;
250
258
  /** Uniform live-activity text for the live status field (reasoning OR derived fallback). */
251
259
  private streamingActivity = "";
252
260
  /** Last stream-driven draw (ms epoch) — throttles per-delta repaints to ≤10/s. */
@@ -410,6 +418,7 @@ export class LaunchTui {
410
418
  this.retryNotice = null; // a new step starts a fresh model call
411
419
  this.streamingReasoning = ""; // fresh model response this step
412
420
  this.streamingThought = "";
421
+ this.thinkingActive = false;
413
422
  this.streamingActivity = "";
414
423
  this.flushedReasoning = "";
415
424
  this.flushedThought = "";
@@ -452,6 +461,14 @@ export class LaunchTui {
452
461
  this.draw();
453
462
  }
454
463
  },
464
+ onReasoningStart: () => {
465
+ // The model opened an extended-thinking block. Signature-only reasoning models
466
+ // (opus-4-7/4-8) stream no thinking text, so flag the thinking phase so the live
467
+ // Thinking block renders a placeholder instead of leaving the wait blank.
468
+ if (this.finished || this.thinkingActive) return;
469
+ this.thinkingActive = true;
470
+ this.draw();
471
+ },
455
472
  onAssistant: (_raw, invocation) => {
456
473
  this.thinking = false; // model replied; now dispatching the tool
457
474
  this.retryNotice = null; // the call got through — clear any backoff notice
@@ -484,6 +501,7 @@ export class LaunchTui {
484
501
  }
485
502
  this.streamingReasoning = "";
486
503
  this.streamingThought = "";
504
+ this.thinkingActive = false;
487
505
  this.streamingActivity = "";
488
506
  if (invocation && invocation.tool !== "done") {
489
507
  this.runningTool = true;
@@ -650,6 +668,18 @@ export class LaunchTui {
650
668
  this.draw();
651
669
  }
652
670
 
671
+ private livePromptHighlight?: { start: number; end: number; paint: (s: string) => string };
672
+ /** Recolor the active `/command`·`$skill` trigger token inside the mid-turn live
673
+ * input box (idle-prompt parity). Caller supplies code-point offsets into the
674
+ * draft text + a painter; undefined clears it. */
675
+ setLivePromptHighlight(hl?: { start: number; end: number; paint: (s: string) => string }): void {
676
+ if (this.finished) return;
677
+ const a = this.livePromptHighlight, b = hl;
678
+ if (a?.start === b?.start && a?.end === b?.end && (!a) === (!b)) return;
679
+ this.livePromptHighlight = hl;
680
+ this.draw();
681
+ }
682
+
653
683
  private livePromptHint: string[] = [];
654
684
  /** Mid-turn command/skill preview lines shown above the live input box, so a
655
685
  * /command or $skill typed WHILE a turn runs visibly reacts (idle-prompt parity). */
@@ -672,6 +702,7 @@ export class LaunchTui {
672
702
  accentShadow: this.theme.color ? accentShadowPaint(this.theme) : undefined,
673
703
  placeholder: "Type your next message...",
674
704
  maxBodyRows: 2,
705
+ highlight: this.livePromptHighlight,
675
706
  });
676
707
  if (this.livePromptHint.length === 0) return box;
677
708
  const dim = this.theme.color ? chalk.dim : (s: string) => s;
@@ -925,6 +956,7 @@ export class LaunchTui {
925
956
  this.lastLedgerKind = null; // fresh turn: no leading spacer before the first ledger line
926
957
  this.livePromptInput = ""; // fresh turn: no next-prompt draft yet
927
958
  this.livePromptHint = []; // fresh turn: no mid-turn command preview yet
959
+ this.livePromptHighlight = undefined; // fresh turn: no active trigger token
928
960
  this.subagentLive = null; // fresh turn: no nested subagent in flight
929
961
  this.activityLog.length = 0; // per-turn ring: timestamps are turn-relative
930
962
  this.spinner.updateStep(0, this.footer.maxSteps);
@@ -1382,6 +1414,13 @@ export class LaunchTui {
1382
1414
  const liveMs = this.currentStepStartedAt ? Date.now() - this.currentStepStartedAt : undefined;
1383
1415
  const liveLabel = liveMs !== undefined ? `Thinking · ${(liveMs / 1000).toFixed(1)}s` : "Thinking";
1384
1416
  tail.push(...this.renderLiveBlock(liveLabel, liveThink, cols, rows, 6, "Thinking"));
1417
+ } else if (isThinking && this.thinkingActive) {
1418
+ // Signature-only reasoning models (opus-4-7/4-8) open a thinking block but stream no
1419
+ // thought text — show a live placeholder so the wait reads as active thinking, not a
1420
+ // frozen screen. Replaced the instant any real thought/answer text streams (branch above).
1421
+ const liveMs = this.currentStepStartedAt ? Date.now() - this.currentStepStartedAt : undefined;
1422
+ const liveLabel = liveMs !== undefined ? `Thinking · ${(liveMs / 1000).toFixed(1)}s` : "Thinking";
1423
+ tail.push(...this.renderLiveBlock(liveLabel, "(thinking…)", cols, rows, 6, "Thinking"));
1385
1424
  }
1386
1425
 
1387
1426
  // Live tool output (gjc-style streaming bash stdout): while a tool runs, its
@@ -1,6 +1,6 @@
1
1
  import chalk from "chalk";
2
2
  import { BOX_ASCII, BOX_UNICODE, boxBlock } from "./layout";
3
- import { visibleWidth } from "./width";
3
+ import { visibleWidth, truncateToWidth } from "./width";
4
4
 
5
5
  export interface InputBoxOptions {
6
6
  cols?: number;
@@ -18,6 +18,11 @@ export interface InputBoxOptions {
18
18
  /** Shadow painter for the bottom/right "shaded" edges; defaults to a dim accent.
19
19
  * The lit-vs-shaded two-tone contrast gives the box visible depth. */
20
20
  accentShadow?: (s: string) => string;
21
+ /** Paint a contiguous CHARACTER range of the typed text (e.g. the active
22
+ * `/command` or `$skill` trigger token) so the user sees the invocation is
23
+ * recognized as it is typed. Offsets index `Array.from(line)` code points
24
+ * ([start, end)). Ignored for the placeholder and when `color` is false. */
25
+ highlight?: { start: number; end: number; paint: (s: string) => string };
21
26
  }
22
27
 
23
28
  export interface InputFrame {
@@ -38,6 +43,7 @@ function wrapWithCursor(
38
43
  text: string,
39
44
  cursor: number,
40
45
  width: number,
46
+ highlight?: { start: number; end: number; paint: (s: string) => string },
41
47
  ): { rows: string[]; row: number; col: number } {
42
48
  const rows: string[] = [];
43
49
  let cur = "";
@@ -67,7 +73,8 @@ function wrapWithCursor(
67
73
  continue;
68
74
  }
69
75
  if (ch !== "") {
70
- cur += rendered;
76
+ const lit = highlight && i >= highlight.start && i < highlight.end;
77
+ cur += lit ? highlight.paint(rendered) : rendered;
71
78
  curW += w;
72
79
  }
73
80
  }
@@ -95,7 +102,7 @@ export function renderInputFrame(line: string, opts: InputBoxOptions = {}): Inpu
95
102
  rows = [placeholder];
96
103
  placeholderRow = true;
97
104
  } else {
98
- const wrapped = wrapWithCursor(line, opts.cursor ?? line.length, textWidth);
105
+ const wrapped = wrapWithCursor(line, opts.cursor ?? line.length, textWidth, useColor ? opts.highlight : undefined);
99
106
  rows = wrapped.rows;
100
107
  crow = wrapped.row;
101
108
  ccol = wrapped.col;
@@ -110,10 +117,10 @@ export function renderInputFrame(line: string, opts: InputBoxOptions = {}): Inpu
110
117
  hidden = Math.min(Math.max(0, crow - maxBodyRows + 1), totalRows - maxBodyRows);
111
118
  if (crow < hidden) hidden = crow; // caret above the window → scroll up to it
112
119
  rows = rows.slice(hidden, hidden + maxBodyRows);
113
- if (hidden > 0) rows[0] = `…${rows[0] ?? ""}`.slice(0, textWidth);
120
+ if (hidden > 0) rows[0] = truncateToWidth(`…${rows[0] ?? ""}`, textWidth);
114
121
  if (hidden + maxBodyRows < totalRows) {
115
122
  const last = rows.length - 1;
116
- rows[last] = `${rows[last] ?? ""}…`.slice(0, textWidth);
123
+ rows[last] = truncateToWidth(`${rows[last] ?? ""}…`, textWidth);
117
124
  }
118
125
  }
119
126
  let visRow = Math.max(0, Math.min(crow - hidden, rows.length - 1));
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Rich, gjc-style session picker for `/resume`.
3
+ *
4
+ * Mirrors Gajae-Code's session selector UX: a search/filter line at the top, a
5
+ * scrolling window of multi-line entries (title + dimmed first-message preview +
6
+ * a "relative time · size · N msgs" metadata line), a position indicator, and a
7
+ * footer with Del-to-delete / Enter-to-resume / Esc-to-cancel hints.
8
+ *
9
+ * Pure rendering — no I/O. The owning REPL drives navigation/deletion via the
10
+ * `SessionPicker` model and feeds the rendered lines to its picker loop.
11
+ */
12
+ import chalk from "chalk";
13
+ import { truncateToWidth } from "./width";
14
+ import type { SessionSummary } from "../../agent/session";
15
+
16
+ /** Human-readable byte size (e.g. "0 B", "12.3 KB", "4.2 MB"). */
17
+ export function formatBytes(n: number | undefined): string {
18
+ const v = typeof n === "number" && Number.isFinite(n) && n >= 0 ? n : 0;
19
+ if (v < 1024) return `${v} B`;
20
+ const units = ["KB", "MB", "GB", "TB"];
21
+ let size = v / 1024;
22
+ let i = 0;
23
+ while (size >= 1024 && i < units.length - 1) {
24
+ size /= 1024;
25
+ i++;
26
+ }
27
+ return `${size < 10 ? size.toFixed(1) : Math.round(size)} ${units[i]}`;
28
+ }
29
+
30
+ /** Relative "X ago" timestamp, matching gjc's session-selector phrasing. */
31
+ export function formatRelativeTime(fromMs: number | undefined, nowMs: number = Date.now()): string {
32
+ if (typeof fromMs !== "number" || !Number.isFinite(fromMs) || fromMs <= 0) return "unknown";
33
+ const diff = Math.max(0, nowMs - fromMs);
34
+ const mins = Math.floor(diff / 60000);
35
+ const hours = Math.floor(diff / 3600000);
36
+ const days = Math.floor(diff / 86400000);
37
+ if (mins < 1) return "just now";
38
+ if (mins < 60) return `${mins} minute${mins !== 1 ? "s" : ""} ago`;
39
+ if (hours < 24) return `${hours} hour${hours !== 1 ? "s" : ""} ago`;
40
+ if (days === 1) return "1 day ago";
41
+ if (days < 7) return `${days} days ago`;
42
+ return new Date(fromMs).toLocaleDateString();
43
+ }
44
+
45
+ /**
46
+ * Navigable model for the resume picker: an ordered session list with a
47
+ * case-insensitive AND-of-terms filter across id/title/preview/cwd, a cursor
48
+ * into the *filtered* view, and in-place removal for delete.
49
+ */
50
+ export class SessionPicker {
51
+ private readonly all: SessionSummary[];
52
+ private query = "";
53
+ private cursor = 0;
54
+
55
+ constructor(sessions: readonly SessionSummary[]) {
56
+ this.all = sessions.slice();
57
+ }
58
+
59
+ /** Sessions matching the current filter (every whitespace term must match). */
60
+ visible(): SessionSummary[] {
61
+ const q = this.query.trim().toLowerCase();
62
+ if (!q) return this.all;
63
+ const terms = q.split(/\s+/);
64
+ return this.all.filter(s => {
65
+ const hay = [s.id, s.title ?? "", s.preview ?? "", s.cwd ?? ""].join(" ").toLowerCase();
66
+ return terms.every(t => hay.includes(t));
67
+ });
68
+ }
69
+
70
+ cursorIndex(): number {
71
+ const n = this.visible().length;
72
+ if (n === 0) return 0;
73
+ return Math.max(0, Math.min(this.cursor, n - 1));
74
+ }
75
+
76
+ selected(): SessionSummary | undefined {
77
+ return this.visible()[this.cursorIndex()];
78
+ }
79
+
80
+ isEmpty(): boolean {
81
+ return this.visible().length === 0;
82
+ }
83
+
84
+ filter(): string {
85
+ return this.query;
86
+ }
87
+
88
+ setFilter(query: string): void {
89
+ this.query = query;
90
+ this.cursor = 0;
91
+ }
92
+
93
+ typeChar(ch: string): void {
94
+ this.setFilter(this.query + ch);
95
+ }
96
+
97
+ backspace(): void {
98
+ this.setFilter(this.query.slice(0, -1));
99
+ }
100
+
101
+ up(): void {
102
+ const n = this.visible().length;
103
+ if (n === 0) return;
104
+ this.cursor = (this.cursorIndex() - 1 + n) % n;
105
+ }
106
+
107
+ down(): void {
108
+ const n = this.visible().length;
109
+ if (n === 0) return;
110
+ this.cursor = (this.cursorIndex() + 1) % n;
111
+ }
112
+
113
+ /** Move by a window without wrapping (PageUp/PageDown). */
114
+ page(dir: 1 | -1, size = 3): void {
115
+ const n = this.visible().length;
116
+ if (n === 0) return;
117
+ this.cursor = Math.max(0, Math.min(n - 1, this.cursorIndex() + dir * Math.max(1, size)));
118
+ }
119
+
120
+ /** Remove the highlighted session from the model; returns it (or undefined). */
121
+ removeSelected(): SessionSummary | undefined {
122
+ const sel = this.selected();
123
+ if (!sel) return undefined;
124
+ const idx = this.all.findIndex(s => s.id === sel.id);
125
+ if (idx >= 0) this.all.splice(idx, 1);
126
+ const n = this.visible().length;
127
+ if (this.cursor >= n) this.cursor = Math.max(0, n - 1);
128
+ return sel;
129
+ }
130
+ }
131
+
132
+ export interface RenderSessionPickerOptions {
133
+ /** Title line(s) shown above the search line. */
134
+ title?: string;
135
+ /** Total width to fit each line to (default 80). */
136
+ cols?: number;
137
+ /** Total body rows available; the visible window is derived from this (default 24). */
138
+ rows?: number;
139
+ /** Use unicode glyphs (default true). */
140
+ unicode?: boolean;
141
+ /** Apply chalk color (default true). */
142
+ color?: boolean;
143
+ /** Clock override for relative-time formatting (tests). */
144
+ nowMs?: number;
145
+ /** When set, the matching session shows a "press Del again to delete" prompt. */
146
+ confirmDeleteId?: string;
147
+ }
148
+
149
+ /** Render a `SessionPicker` to lines (gjc-style multi-line entries). Pure. */
150
+ export function renderSessionPicker(picker: SessionPicker, opts: RenderSessionPickerOptions = {}): string[] {
151
+ const unicode = opts.unicode !== false;
152
+ const color = opts.color !== false;
153
+ const cols = Math.max(20, opts.cols ?? 80);
154
+ const nowMs = opts.nowMs ?? Date.now();
155
+ const tint = (s: string, fn: (x: string) => string): string => (color ? fn(s) : s);
156
+ const fit = (s: string): string => truncateToWidth(s, cols);
157
+ const pointer = unicode ? "\u276f" : ">"; // ❯
158
+ const dot = unicode ? "\u00b7" : "-"; // ·
159
+ const arrow = unicode ? "\u203a" : ">"; // ›
160
+
161
+ const out: string[] = [];
162
+ const titleLines = opts.title ? opts.title.split("\n") : [];
163
+ for (const t of titleLines) out.push(fit(t ? tint(t, chalk.bold) : ""));
164
+
165
+ // Search/filter line (gjc places an input box at the top).
166
+ const q = picker.filter();
167
+ const searchValue = q ? q : tint("type to filter", chalk.gray);
168
+ out.push(fit(`${tint("search", chalk.gray)} ${tint(arrow, chalk.cyan)} ${searchValue}`));
169
+ out.push("");
170
+
171
+ const items = picker.visible();
172
+ const footerKeys = unicode
173
+ ? `\u2191/\u2193 move \u00b7 enter resume \u00b7 del delete \u00b7 esc cancel`
174
+ : `up/down move - enter resume - del delete - esc cancel`;
175
+
176
+ if (items.length === 0) {
177
+ out.push(fit(tint(" no sessions match", chalk.gray)));
178
+ out.push("");
179
+ out.push(fit(tint(` [${footerKeys}]`, chalk.gray)));
180
+ return out;
181
+ }
182
+
183
+ // Each entry occupies up to 3 content lines + 1 blank; derive the window from
184
+ // available rows, leaving room for title/search/footer chrome.
185
+ const linesPerItem = 4;
186
+ const chrome = titleLines.length + 2 /* search + blank */ + 2 /* position + footer */;
187
+ const avail = Math.max(linesPerItem, (opts.rows ?? 24) - chrome);
188
+ const maxVisible = Math.max(1, Math.min(items.length, Math.floor(avail / linesPerItem)));
189
+
190
+ const cur = picker.cursorIndex();
191
+ let start = Math.max(0, cur - Math.floor(maxVisible / 2));
192
+ start = Math.min(start, Math.max(0, items.length - maxVisible));
193
+ const end = Math.min(items.length, start + maxVisible);
194
+
195
+ for (let i = start; i < end; i++) {
196
+ const s = items[i]!;
197
+ const isCur = i === cur;
198
+ const isConfirm = !!opts.confirmDeleteId && s.id === opts.confirmDeleteId;
199
+ const cursorStr = isCur ? tint(`${pointer} `, chalk.cyan) : " ";
200
+ const maxw = Math.max(1, cols - 2); // cursor/indent prefix is 2 columns
201
+ const firstMsg = (s.preview || "(no preview)").replace(/\s+/g, " ").trim();
202
+
203
+ if (s.title) {
204
+ const titleTxt = truncateToWidth(s.title, maxw);
205
+ out.push(fit(cursorStr + (isCur ? tint(titleTxt, (x: string) => chalk.cyan.bold(x)) : titleTxt)));
206
+ out.push(fit(" " + tint(truncateToWidth(firstMsg, maxw), chalk.dim)));
207
+ } else {
208
+ const msg = truncateToWidth(firstMsg, maxw);
209
+ out.push(fit(cursorStr + (isCur ? tint(msg, (x: string) => chalk.cyan.bold(x)) : msg)));
210
+ }
211
+
212
+ if (isConfirm) {
213
+ out.push(fit(tint(` press Del again to delete ${dot} any other key cancels`, chalk.yellow)));
214
+ } else {
215
+ const meta = ` ${formatRelativeTime(s.mtimeMs, nowMs)} ${dot} ${formatBytes(s.sizeBytes)} ${dot} ${s.messageCount} msg${s.messageCount !== 1 ? "s" : ""}`;
216
+ out.push(fit(tint(truncateToWidth(meta, cols), chalk.dim)));
217
+ }
218
+ out.push("");
219
+ }
220
+
221
+ if (start > 0 || end < items.length) {
222
+ out.push(fit(tint(` (${cur + 1}/${items.length})`, chalk.gray)));
223
+ }
224
+ out.push(fit(tint(` [${footerKeys}]`, chalk.gray)));
225
+ return out;
226
+ }