@oh-my-pi/pi-coding-agent 17.0.4 → 17.0.6

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 (143) hide show
  1. package/CHANGELOG.md +209 -0
  2. package/dist/cli.js +3616 -3676
  3. package/dist/types/advisor/advise-tool.d.ts +3 -0
  4. package/dist/types/advisor/runtime.d.ts +7 -1
  5. package/dist/types/async/job-manager.d.ts +2 -0
  6. package/dist/types/config/model-registry.d.ts +7 -0
  7. package/dist/types/config/model-resolver.d.ts +8 -0
  8. package/dist/types/config/models-config-schema.d.ts +10 -0
  9. package/dist/types/config/models-config.d.ts +6 -0
  10. package/dist/types/dap/client.d.ts +10 -0
  11. package/dist/types/dap/types.d.ts +6 -5
  12. package/dist/types/exec/bash-executor.d.ts +1 -0
  13. package/dist/types/extensibility/extensions/load-errors.d.ts +3 -0
  14. package/dist/types/extensibility/extensions/runner.d.ts +4 -2
  15. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +16 -0
  16. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +8 -0
  17. package/dist/types/launch/spawn-options.d.ts +10 -0
  18. package/dist/types/launch/spawn-options.test.d.ts +1 -0
  19. package/dist/types/modes/components/status-line/component.d.ts +10 -3
  20. package/dist/types/modes/components/status-line/types.d.ts +3 -1
  21. package/dist/types/modes/components/tool-execution.d.ts +2 -0
  22. package/dist/types/modes/components/transcript-container.d.ts +4 -3
  23. package/dist/types/modes/components/tree-selector.d.ts +6 -2
  24. package/dist/types/modes/components/usage-row.d.ts +1 -1
  25. package/dist/types/modes/interactive-mode.d.ts +2 -0
  26. package/dist/types/modes/print-mode.d.ts +4 -0
  27. package/dist/types/modes/rpc/rpc-client.d.ts +1 -1
  28. package/dist/types/modes/rpc/rpc-frame.d.ts +9 -0
  29. package/dist/types/modes/rpc/rpc-input.d.ts +5 -0
  30. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  31. package/dist/types/modes/setup-wizard/index.d.ts +1 -1
  32. package/dist/types/modes/setup-wizard/scenes/model.d.ts +3 -0
  33. package/dist/types/modes/types.d.ts +2 -0
  34. package/dist/types/sdk.d.ts +2 -0
  35. package/dist/types/session/agent-session.d.ts +42 -6
  36. package/dist/types/session/messages.d.ts +6 -0
  37. package/dist/types/session/session-paths.d.ts +21 -4
  38. package/dist/types/tools/browser/aria/aria-snapshot.d.ts +17 -7
  39. package/dist/types/tools/browser/cmux/socket-client.d.ts +2 -0
  40. package/dist/types/tools/browser/tab-protocol.d.ts +2 -0
  41. package/dist/types/tools/browser/tab-worker.d.ts +2 -0
  42. package/dist/types/tools/gh.d.ts +5 -3
  43. package/dist/types/tools/hub/index.d.ts +1 -1
  44. package/dist/types/tools/hub/jobs.d.ts +1 -0
  45. package/dist/types/tools/hub/types.d.ts +2 -0
  46. package/dist/types/tools/tool-timeouts.d.ts +1 -1
  47. package/dist/types/tools/xdev.d.ts +5 -3
  48. package/dist/types/utils/git.d.ts +33 -0
  49. package/dist/types/vibe/__tests__/token-rate.test.d.ts +1 -0
  50. package/dist/types/vibe/runtime.d.ts +23 -0
  51. package/dist/types/web/search/providers/codex.d.ts +1 -1
  52. package/package.json +12 -12
  53. package/src/advisor/__tests__/advisor.test.ts +150 -0
  54. package/src/advisor/advise-tool.ts +4 -0
  55. package/src/advisor/runtime.ts +38 -14
  56. package/src/async/job-manager.ts +3 -0
  57. package/src/cli/bench-cli.ts +8 -2
  58. package/src/cli/dry-balance-cli.ts +1 -0
  59. package/src/config/model-registry.ts +89 -8
  60. package/src/config/model-resolver.ts +78 -14
  61. package/src/config/models-config-schema.ts +1 -0
  62. package/src/config/settings.ts +3 -1
  63. package/src/dap/client.ts +168 -1
  64. package/src/dap/config.ts +51 -1
  65. package/src/dap/session.ts +575 -234
  66. package/src/dap/types.ts +6 -5
  67. package/src/discovery/agents.ts +2 -2
  68. package/src/exec/bash-executor.ts +68 -8
  69. package/src/extensibility/extensions/load-errors.ts +13 -0
  70. package/src/extensibility/extensions/runner.ts +6 -4
  71. package/src/extensibility/legacy-pi-coding-agent-shim.ts +26 -0
  72. package/src/extensibility/plugins/legacy-pi-compat.ts +41 -6
  73. package/src/launch/broker.ts +34 -31
  74. package/src/launch/client.ts +7 -1
  75. package/src/launch/spawn-options.test.ts +31 -0
  76. package/src/launch/spawn-options.ts +17 -0
  77. package/src/lsp/types.ts +5 -1
  78. package/src/main.ts +25 -4
  79. package/src/mcp/transports/stdio.test.ts +9 -1
  80. package/src/modes/components/ask-dialog.ts +137 -73
  81. package/src/modes/components/chat-transcript-builder.ts +10 -1
  82. package/src/modes/components/status-line/component.ts +57 -3
  83. package/src/modes/components/status-line/segments.ts +20 -3
  84. package/src/modes/components/status-line/types.ts +3 -1
  85. package/src/modes/components/tool-execution.ts +5 -0
  86. package/src/modes/components/transcript-container.ts +23 -122
  87. package/src/modes/components/tree-selector.ts +10 -4
  88. package/src/modes/components/usage-row.ts +17 -1
  89. package/src/modes/controllers/command-controller.ts +41 -1
  90. package/src/modes/controllers/event-controller.ts +7 -3
  91. package/src/modes/controllers/input-controller.ts +1 -1
  92. package/src/modes/controllers/selector-controller.ts +29 -8
  93. package/src/modes/interactive-mode.ts +102 -60
  94. package/src/modes/noninteractive-dispose.test.ts +14 -1
  95. package/src/modes/print-mode.ts +81 -9
  96. package/src/modes/rpc/rpc-client.ts +76 -35
  97. package/src/modes/rpc/rpc-frame.ts +156 -0
  98. package/src/modes/rpc/rpc-input.ts +38 -0
  99. package/src/modes/rpc/rpc-mode.ts +11 -4
  100. package/src/modes/setup-wizard/index.ts +2 -0
  101. package/src/modes/setup-wizard/scenes/model.ts +134 -0
  102. package/src/modes/setup-wizard/scenes/sign-in.ts +3 -1
  103. package/src/modes/types.ts +2 -0
  104. package/src/modes/utils/ui-helpers.ts +9 -3
  105. package/src/prompts/system/workflow-notice.md +89 -74
  106. package/src/prompts/tools/browser.md +3 -2
  107. package/src/prompts/tools/debug.md +2 -7
  108. package/src/prompts/tools/github.md +6 -1
  109. package/src/prompts/tools/hub.md +4 -2
  110. package/src/prompts/tools/task-async-contract.md +1 -0
  111. package/src/prompts/tools/task.md +9 -2
  112. package/src/sdk.ts +38 -6
  113. package/src/session/agent-session.ts +484 -188
  114. package/src/session/messages.test.ts +91 -0
  115. package/src/session/messages.ts +248 -110
  116. package/src/session/session-manager.ts +34 -3
  117. package/src/session/session-paths.ts +38 -9
  118. package/src/slash-commands/builtin-registry.ts +1 -0
  119. package/src/task/executor.ts +104 -33
  120. package/src/task/index.ts +46 -9
  121. package/src/task/worktree.ts +10 -0
  122. package/src/tools/browser/aria/aria-snapshot.ts +36 -8
  123. package/src/tools/browser/cmux/cmux-tab.ts +2 -1
  124. package/src/tools/browser/cmux/socket-client.ts +139 -3
  125. package/src/tools/browser/run-cancellation.ts +4 -0
  126. package/src/tools/browser/tab-protocol.ts +2 -0
  127. package/src/tools/browser/tab-supervisor.ts +21 -11
  128. package/src/tools/browser/tab-worker.ts +199 -33
  129. package/src/tools/debug.ts +3 -0
  130. package/src/tools/gh.ts +40 -2
  131. package/src/tools/hub/index.ts +4 -1
  132. package/src/tools/hub/jobs.ts +42 -1
  133. package/src/tools/hub/types.ts +2 -0
  134. package/src/tools/tool-timeouts.ts +1 -1
  135. package/src/tools/xdev.ts +11 -4
  136. package/src/tools/yield.ts +4 -1
  137. package/src/utils/git.ts +237 -0
  138. package/src/{modes/components/status-line → utils}/token-rate.ts +6 -0
  139. package/src/vibe/__tests__/token-rate.test.ts +96 -0
  140. package/src/vibe/runtime.ts +50 -0
  141. package/src/web/search/index.ts +9 -5
  142. package/src/web/search/providers/codex.ts +195 -99
  143. /package/dist/types/{modes/components/status-line → utils}/token-rate.d.ts +0 -0
@@ -0,0 +1,156 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { isRecord } from "@oh-my-pi/pi-utils";
3
+
4
+ /** Maximum UTF-8 size of one newline-delimited RPC frame, including the newline. */
5
+ export const MAX_RPC_FRAME_BYTES = 1024 * 1024;
6
+
7
+ interface ShrinkPass {
8
+ stringCap: number;
9
+ arrayLimit: number;
10
+ objectLimit: number;
11
+ }
12
+
13
+ const SHRINK_PASSES: readonly ShrinkPass[] = [
14
+ { stringCap: 256 * 1024, arrayLimit: 512, objectLimit: 512 },
15
+ { stringCap: 64 * 1024, arrayLimit: 256, objectLimit: 256 },
16
+ { stringCap: 16 * 1024, arrayLimit: 128, objectLimit: 128 },
17
+ { stringCap: 4 * 1024, arrayLimit: 64, objectLimit: 64 },
18
+ { stringCap: 1024, arrayLimit: 32, objectLimit: 32 },
19
+ { stringCap: 256, arrayLimit: 8, objectLimit: 16 },
20
+ { stringCap: 64, arrayLimit: 1, objectLimit: 8 },
21
+ ];
22
+
23
+ const STRING_ELISION_RESERVE = 80;
24
+ const METADATA_STRING_CAP = 1024;
25
+
26
+ function serializedFrameBytes(json: string): number {
27
+ return Buffer.byteLength(json, "utf8") + 1;
28
+ }
29
+
30
+ function shrinkString(value: string, cap: number): string {
31
+ if (value.length <= cap) return value;
32
+ const headLength = Math.max(0, cap - STRING_ELISION_RESERVE);
33
+ return `${value.slice(0, headLength)}\n…[${value.length - headLength} chars elided for RPC frame]`;
34
+ }
35
+
36
+ function shrinkValue(value: unknown, pass: ShrinkPass): unknown {
37
+ if (typeof value === "string") return shrinkString(value, pass.stringCap);
38
+ if (Array.isArray(value)) {
39
+ const keep = Math.min(value.length, pass.arrayLimit);
40
+ const output: unknown[] = new Array(keep + (keep < value.length ? 1 : 0));
41
+ for (let index = 0; index < keep; index++) output[index] = shrinkValue(value[index], pass);
42
+ if (keep < value.length) output[keep] = `…[${value.length - keep} items elided for RPC frame]`;
43
+ return output;
44
+ }
45
+ if (isRecord(value)) {
46
+ const entries = Object.entries(value);
47
+ const keep = Math.min(entries.length, pass.objectLimit);
48
+ const output: Record<string, unknown> = {};
49
+ for (let index = 0; index < keep; index++) {
50
+ const [key, item] = entries[index];
51
+ output[key] = shrinkValue(item, pass);
52
+ }
53
+ if (keep < entries.length) output.rpcFrameElidedKeys = entries.length - keep;
54
+ return output;
55
+ }
56
+ return value;
57
+ }
58
+
59
+ function jsonSnapshot(value: unknown): unknown {
60
+ const json = JSON.stringify(value);
61
+ return json === undefined ? undefined : JSON.parse(json);
62
+ }
63
+
64
+ function encodedMessageSnapshot(encoded: string): { message: unknown } | undefined {
65
+ const frame = JSON.parse(encoded);
66
+ return isRecord(frame) && frame.type === "message_end" && Object.hasOwn(frame, "message")
67
+ ? { message: frame.message }
68
+ : undefined;
69
+ }
70
+
71
+ function compactTerminalFrame(
72
+ frame: object,
73
+ streamedMessageCount: number,
74
+ streamedMessages?: readonly unknown[],
75
+ ): object {
76
+ if (!isRecord(frame) || frame.type !== "agent_end" || !Array.isArray(frame.messages)) return frame;
77
+ let streamed = Number.isSafeInteger(streamedMessageCount)
78
+ ? Math.min(Math.max(0, streamedMessageCount), frame.messages.length)
79
+ : 0;
80
+ if (streamedMessages) {
81
+ streamed = 0;
82
+ const limit = Math.min(streamedMessages.length, frame.messages.length);
83
+ while (
84
+ streamed < limit &&
85
+ isDeepStrictEqual(streamedMessages[streamed], jsonSnapshot(frame.messages[streamed]))
86
+ ) {
87
+ streamed++;
88
+ }
89
+ }
90
+ return {
91
+ ...frame,
92
+ messages: frame.messages.slice(streamed),
93
+ messageCount: frame.messages.length,
94
+ };
95
+ }
96
+
97
+ function overflowFrame(frame: object): object {
98
+ if (!isRecord(frame)) return { type: "rpc_frame_error", error: "RPC frame exceeded the transport limit" };
99
+ if (frame.type === "response") {
100
+ return {
101
+ id: typeof frame.id === "string" ? shrinkString(frame.id, METADATA_STRING_CAP) : undefined,
102
+ type: "response",
103
+ command: typeof frame.command === "string" ? shrinkString(frame.command, METADATA_STRING_CAP) : "unknown",
104
+ success: false,
105
+ error: "RPC response exceeded the transport limit",
106
+ };
107
+ }
108
+ if (frame.type === "agent_end") {
109
+ return {
110
+ type: "agent_end",
111
+ messages: [],
112
+ messageCount: typeof frame.messageCount === "number" ? frame.messageCount : 0,
113
+ };
114
+ }
115
+ return {
116
+ type: "rpc_frame_error",
117
+ originalType: typeof frame.type === "string" ? shrinkString(frame.type, METADATA_STRING_CAP) : undefined,
118
+ error: "RPC frame exceeded the transport limit",
119
+ };
120
+ }
121
+
122
+ /** Serialize a complete JSONL frame while enforcing the transport byte ceiling. */
123
+ export function encodeRpcFrame(frame: object, streamedMessageCount = 0, streamedMessages?: readonly unknown[]): string {
124
+ let json = JSON.stringify(frame);
125
+ if (serializedFrameBytes(json) <= MAX_RPC_FRAME_BYTES) return `${json}\n`;
126
+ if (isRecord(frame) && frame.type === "response") {
127
+ return `${JSON.stringify(overflowFrame(frame))}\n`;
128
+ }
129
+
130
+ const compacted = compactTerminalFrame(frame, streamedMessageCount, streamedMessages);
131
+ json = JSON.stringify(compacted);
132
+ if (serializedFrameBytes(json) <= MAX_RPC_FRAME_BYTES) return `${json}\n`;
133
+
134
+ for (const pass of SHRINK_PASSES) {
135
+ json = JSON.stringify(shrinkValue(compacted, pass));
136
+ if (serializedFrameBytes(json) <= MAX_RPC_FRAME_BYTES) return `${json}\n`;
137
+ }
138
+
139
+ return `${JSON.stringify(overflowFrame(compacted))}\n`;
140
+ }
141
+
142
+ /** Stateful encoder that tracks which messages a client has already received. */
143
+ export class RpcFrameEncoder {
144
+ #streamedMessages: unknown[] = [];
145
+
146
+ encode(frame: object): string {
147
+ if (isRecord(frame) && frame.type === "agent_start") this.#streamedMessages = [];
148
+ const encoded = encodeRpcFrame(frame, this.#streamedMessages.length, this.#streamedMessages);
149
+ if (!isRecord(frame)) return encoded;
150
+ if (frame.type === "message_end") {
151
+ const snapshot = encodedMessageSnapshot(encoded);
152
+ if (snapshot) this.#streamedMessages.push(snapshot.message);
153
+ } else if (frame.type === "agent_end" && frame.willContinue !== true) this.#streamedMessages = [];
154
+ return encoded;
155
+ }
156
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Claims Bun's singleton stdin reader immediately and exposes a separately readable stream.
3
+ * RPC startup uses this before extension discovery so in-process modules cannot steal protocol input.
4
+ */
5
+ export function claimRpcInput(): ReadableStream<Uint8Array> {
6
+ const reader = Bun.stdin.stream().getReader();
7
+ let released = false;
8
+ const release = () => {
9
+ if (released) return;
10
+ released = true;
11
+ try {
12
+ reader.releaseLock();
13
+ } catch {}
14
+ };
15
+ return new ReadableStream({
16
+ async pull(controller) {
17
+ try {
18
+ const result = await reader.read();
19
+ if (result.done) {
20
+ release();
21
+ controller.close();
22
+ } else {
23
+ controller.enqueue(result.value);
24
+ }
25
+ } catch (error) {
26
+ release();
27
+ controller.error(error);
28
+ }
29
+ },
30
+ async cancel() {
31
+ try {
32
+ await reader.cancel();
33
+ } finally {
34
+ release();
35
+ }
36
+ },
37
+ });
38
+ }
@@ -34,6 +34,8 @@ import type { EventBus } from "../../utils/event-bus";
34
34
  import { initializeExtensions } from "../runtime-init";
35
35
  import { isRpcHostToolResult, isRpcHostToolUpdate, RpcHostToolBridge } from "./host-tools";
36
36
  import { isRpcHostUriResult, RpcHostUriBridge } from "./host-uris";
37
+ import { RpcFrameEncoder } from "./rpc-frame";
38
+ import { claimRpcInput } from "./rpc-input";
37
39
  import { RpcSubagentRegistry, readRpcSubagentTranscript } from "./rpc-subagents";
38
40
  import type {
39
41
  RpcCommand,
@@ -607,6 +609,7 @@ export async function runRpcMode(
607
609
  session: AgentSession,
608
610
  setToolUIContext?: (uiContext: ExtensionUIContext, hasUI: boolean) => void,
609
611
  eventBus?: EventBus,
612
+ input: ReadableStream<Uint8Array> = claimRpcInput(),
610
613
  ): Promise<never> {
611
614
  // Signal to RPC clients that the server is ready to accept commands
612
615
  // Suppress terminal notifications: they write \x07 (BEL) or OSC sequences directly to
@@ -615,9 +618,10 @@ export async function runRpcMode(
615
618
  // may write there.
616
619
  process.env.PI_NOTIFICATIONS = "off";
617
620
 
618
- process.stdout.write(`${JSON.stringify({ type: "ready" })}\n`);
621
+ const frameEncoder = new RpcFrameEncoder();
622
+ process.stdout.write(frameEncoder.encode({ type: "ready" }));
619
623
  const output = (obj: RpcResponse | RpcExtensionUIRequest | object) => {
620
- process.stdout.write(`${JSON.stringify(obj)}\n`);
624
+ process.stdout.write(frameEncoder.encode(obj));
621
625
  };
622
626
  const emitRpcTitles = shouldEmitRpcTitles();
623
627
 
@@ -1327,7 +1331,10 @@ export async function runRpcMode(
1327
1331
  return (await uiCtx.input(prompt.message, prompt.placeholder, { timeout: 600_000 })) ?? "";
1328
1332
  },
1329
1333
  });
1330
- await session.modelRegistry.refresh();
1334
+ // Provider-scoped online refresh so the just-persisted credential
1335
+ // re-runs discovery instead of reusing a fresh authoritative cache
1336
+ // row (#5780).
1337
+ await session.modelRegistry.refreshProvider(command.providerId, "online");
1331
1338
  return success(id, "login", { providerId: command.providerId });
1332
1339
  } catch (err: unknown) {
1333
1340
  return error(id, "login", err instanceof Error ? err.message : String(err));
@@ -1381,7 +1388,7 @@ export async function runRpcMode(
1381
1388
  // line is reported as an error frame and the loop keeps running instead of
1382
1389
  // throwing out of the generator and killing the whole process (issue #5194).
1383
1390
  const decoder = new TextDecoder();
1384
- for await (const line of readLines(Bun.stdin.stream())) {
1391
+ for await (const line of readLines(input ?? Bun.stdin.stream())) {
1385
1392
  const text = decoder.decode(line).trim();
1386
1393
  if (!text) continue;
1387
1394
  let parsed: unknown;
@@ -2,6 +2,7 @@ import type { Settings } from "../../config/settings";
2
2
  import { CURRENT_SETUP_VERSION } from "../setup-version";
3
3
  import type { InteractiveModeContext } from "../types";
4
4
  import { glyphSetupScene } from "./scenes/glyph";
5
+ import { modelSetupScene } from "./scenes/model";
5
6
  import { providersSetupScene } from "./scenes/providers";
6
7
  import { themeSetupScene } from "./scenes/theme";
7
8
  import type { SetupScene } from "./scenes/types";
@@ -14,6 +15,7 @@ export { CURRENT_SETUP_VERSION };
14
15
 
15
16
  export const ALL_SCENES = [
16
17
  providersSetupScene,
18
+ modelSetupScene,
17
19
  glyphSetupScene,
18
20
  themeSetupScene,
19
21
  ] as const satisfies readonly SetupScene[];
@@ -0,0 +1,134 @@
1
+ import type { Model } from "@oh-my-pi/pi-ai";
2
+ import type { SgrMouseEvent } from "@oh-my-pi/pi-tui";
3
+ import {
4
+ buildBrowserItems,
5
+ ModelBrowser,
6
+ resolveRoleAssignments,
7
+ sortModelItems,
8
+ } from "../../components/model-browser";
9
+ import { theme } from "../../theme/theme";
10
+ import type { SetupScene, SetupSceneController, SetupSceneHost } from "./types";
11
+
12
+ const MAX_VISIBLE_MODELS = 10;
13
+ const WIZARD_SCREEN_RESERVE = 22;
14
+
15
+ class ModelSceneController implements SetupSceneController {
16
+ title = "Choose your default model";
17
+ subtitle = "Search configured models and save the model used for new sessions.";
18
+ #browser: ModelBrowser;
19
+ #status: string | undefined;
20
+ #selecting = false;
21
+ #disposed = false;
22
+ #browserRowStart = 2;
23
+
24
+ constructor(private readonly host: SetupSceneHost) {
25
+ this.#browser = new ModelBrowser(host.ctx.settings);
26
+ this.#browser.onActivate = item => {
27
+ void this.#select(item.model, item.selector);
28
+ };
29
+ this.#browser.onCancel = () => host.finish("skipped");
30
+ this.#syncModels();
31
+ }
32
+
33
+ async onMount(): Promise<void> {
34
+ this.#status = theme.fg("muted", "Discovering available models…");
35
+ this.host.requestRender();
36
+ await this.#refreshModels();
37
+ }
38
+
39
+ dispose(): void {
40
+ this.#disposed = true;
41
+ }
42
+
43
+ invalidate(): void {
44
+ this.#browser.invalidate();
45
+ }
46
+
47
+ handleInput(data: string): void {
48
+ if (this.#selecting) return;
49
+ this.#browser.handleInput(data);
50
+ }
51
+
52
+ routeMouse(event: SgrMouseEvent, line: number): void {
53
+ if (this.#selecting) return;
54
+ this.#browser.routeMouse(event, line - this.#browserRowStart);
55
+ }
56
+
57
+ render(width: number): readonly string[] {
58
+ const visibleRows = Math.max(
59
+ 1,
60
+ Math.min(MAX_VISIBLE_MODELS, this.host.ctx.ui.terminal.rows - WIZARD_SCREEN_RESERVE),
61
+ );
62
+ this.#browser.setMaxVisible(visibleRows);
63
+ const lines = [
64
+ this.#status ?? theme.fg("muted", "Type to search. Enter saves the highlighted model as your default."),
65
+ "",
66
+ ];
67
+ this.#browserRowStart = lines.length;
68
+ lines.push(...this.#browser.render(width));
69
+ return lines;
70
+ }
71
+
72
+ #syncModels(): void {
73
+ const registry = this.host.ctx.session.modelRegistry;
74
+ const available = registry.getAvailable();
75
+ const roles = resolveRoleAssignments(this.host.ctx.settings, registry.getAll(), available);
76
+ const storage = this.host.ctx.settings.getStorage();
77
+ const items = buildBrowserItems(available);
78
+ sortModelItems(items, { roles, mruOrder: storage?.getModelUsageOrder() ?? [] });
79
+ this.#browser.setRoles(roles);
80
+ this.#browser.setMruOrder(storage?.getModelUsageOrder() ?? []);
81
+ this.#browser.setPerfStats(storage?.getModelPerf() ?? new Map());
82
+ this.#browser.setItems(items);
83
+
84
+ const current = this.host.ctx.session.model;
85
+ if (current) {
86
+ const selector = `${current.provider}/${current.id}`;
87
+ this.#browser.setCurrentSelector(selector);
88
+ this.#browser.selectSelector(selector);
89
+ }
90
+ }
91
+
92
+ async #refreshModels(): Promise<void> {
93
+ try {
94
+ await this.host.ctx.session.modelRegistry.refresh("online-if-uncached");
95
+ if (this.#disposed) return;
96
+ this.#syncModels();
97
+ this.#status = undefined;
98
+ this.host.requestRender();
99
+ } catch (error) {
100
+ if (this.#disposed) return;
101
+ this.#status = theme.fg("error", error instanceof Error ? error.message : String(error));
102
+ this.host.requestRender();
103
+ }
104
+ }
105
+
106
+ async #select(model: Model, selector: string): Promise<void> {
107
+ if (this.#selecting) return;
108
+ this.#selecting = true;
109
+ this.#status = theme.fg("muted", `Saving ${selector} as the default model…`);
110
+ this.host.requestRender();
111
+ try {
112
+ const projectScope = this.host.ctx.settings.get("modelRoleStorage") === "project";
113
+ await this.host.ctx.session.setModel(model, "default", { selector, persist: !projectScope });
114
+ if (projectScope) {
115
+ this.host.ctx.settings.setProjectModelRole("default", selector);
116
+ }
117
+ await this.host.ctx.settings.flush();
118
+ if (!this.#disposed) this.host.finish("done");
119
+ } catch (error) {
120
+ if (this.#disposed) return;
121
+ this.#selecting = false;
122
+ this.#status = theme.fg("error", error instanceof Error ? error.message : String(error));
123
+ this.host.requestRender();
124
+ }
125
+ }
126
+ }
127
+
128
+ /** Setup step that assigns one available model to the persisted default role. */
129
+ export const modelSetupScene: SetupScene = {
130
+ id: "model",
131
+ title: "Choose your default model",
132
+ minVersion: 1,
133
+ mount: host => new ModelSceneController(host),
134
+ };
@@ -224,7 +224,9 @@ export class SignInTab implements SetupTab {
224
224
  onManualCodeInput: () =>
225
225
  this.#showPrompt({ message: "Paste the authorization code (or full redirect URL):" }),
226
226
  });
227
- await this.host.ctx.session.modelRegistry.refresh();
227
+ // Provider-scoped online refresh so the just-persisted credential re-runs
228
+ // discovery instead of reusing a fresh authoritative cache row (#5780).
229
+ await this.host.ctx.session.modelRegistry.refreshProvider(providerId, "online");
228
230
  if (this.#disposed) return;
229
231
  this.#statusLines = [
230
232
  theme.fg("success", `${theme.status.success} Signed in to ${providerId}`),
@@ -159,6 +159,7 @@ export interface InteractiveModeContext {
159
159
  goalModeEnabled: boolean;
160
160
  goalModePaused: boolean;
161
161
  loopModeEnabled: boolean;
162
+ loopModePaused: boolean;
162
163
  loopPrompt?: string;
163
164
  loopLimit?: LoopLimitRuntime;
164
165
  planModePlanFilePath?: string;
@@ -413,6 +414,7 @@ export interface InteractiveModeContext {
413
414
  handleGoalModeCommand(rest?: string): Promise<void>;
414
415
  handleGuidedGoalCommand(rest?: string): Promise<void>;
415
416
  handleLoopCommand(args?: string): Promise<string | undefined>;
417
+ setLoopPrompt(prompt: string): void;
416
418
  disableLoopMode(): void;
417
419
  pauseLoop(): void;
418
420
  handlePlanApproval(details: PlanApprovalDetails): Promise<void>;
@@ -301,14 +301,18 @@ export class UiHelpers {
301
301
  let pendingUsage: Usage | undefined;
302
302
  let pendingUsageDuration: number | undefined;
303
303
  let pendingUsageTtft: number | undefined;
304
+ let pendingUsageTimestamp: number | undefined;
304
305
  const flushPendingUsage = () => {
305
306
  if (!pendingUsage) return;
306
307
  readGroup?.seal();
307
308
  readGroup = null;
308
- this.ctx.chatContainer.addChild(createUsageRowBlock(pendingUsage, pendingUsageDuration, pendingUsageTtft));
309
+ this.ctx.chatContainer.addChild(
310
+ createUsageRowBlock(pendingUsage, pendingUsageDuration, pendingUsageTtft, pendingUsageTimestamp),
311
+ );
309
312
  pendingUsage = undefined;
310
313
  pendingUsageDuration = undefined;
311
314
  pendingUsageTtft = undefined;
315
+ pendingUsageTimestamp = undefined;
312
316
  };
313
317
  // Rebuild-time mirror of the event controller's displaceable-poll
314
318
  // bookkeeping: a `hub` wait that found every watched job still running is
@@ -509,6 +513,7 @@ export class UiHelpers {
509
513
  : undefined;
510
514
  pendingUsageDuration = message.duration;
511
515
  pendingUsageTtft = message.ttft;
516
+ pendingUsageTimestamp = message.timestamp;
512
517
  } else if (message.role === "toolResult") {
513
518
  const pendingReadComponent = this.ctx.pendingTools.get(message.toolCallId);
514
519
  const isReadGroupResult =
@@ -519,10 +524,10 @@ export class UiHelpers {
519
524
  const images: ImageContent[] = message.content.filter(
520
525
  (content): content is ImageContent => content.type === "image",
521
526
  );
522
- if (images.length > 0 && assistantComponent && settings.get("terminal.showImages")) {
527
+ if (images.length > 0 && assistantComponent) {
523
528
  assistantComponent.setToolResultImages(message.toolCallId, images);
524
529
  const hasText = message.content.some(c => c.type === "text");
525
- if (!hasText) {
530
+ if (!hasText && settings.get("terminal.showImages")) {
526
531
  readToolCallArgs.delete(message.toolCallId);
527
532
  readToolCallAssistantComponents.delete(message.toolCallId);
528
533
  continue;
@@ -746,6 +751,7 @@ export class UiHelpers {
746
751
  const hintText = theme.fg("dim", ` ${theme.tree.hook} ${dequeueKey} to edit`);
747
752
  this.ctx.pendingMessagesContainer.addChild(new TruncatedText(hintText, 1, 0));
748
753
  }
754
+ this.ctx.ui.requestComponentRender(this.ctx.pendingMessagesContainer);
749
755
  }
750
756
 
751
757
  queueCompactionMessage(text: string, mode: "steer" | "followUp", images?: ImageContent[]): void {