neoctl 0.1.19 → 0.1.21

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 (120) hide show
  1. package/dist/agents/local-agent-task.js +2 -1
  2. package/dist/agents/local-agent-task.js.map +1 -1
  3. package/dist/agents/smoke-agents.js +21 -4
  4. package/dist/agents/smoke-agents.js.map +1 -1
  5. package/dist/context/prompts.js +4 -0
  6. package/dist/context/prompts.js.map +1 -1
  7. package/dist/core/image-storage.d.ts +6 -0
  8. package/dist/core/image-storage.js +38 -0
  9. package/dist/core/image-storage.js.map +1 -0
  10. package/dist/core/query-engine.d.ts +21 -1
  11. package/dist/core/query-engine.js +103 -13
  12. package/dist/core/query-engine.js.map +1 -1
  13. package/dist/core/query.d.ts +2 -1
  14. package/dist/core/query.js +60 -5
  15. package/dist/core/query.js.map +1 -1
  16. package/dist/core/smoke-core-loop.js +95 -6
  17. package/dist/core/smoke-core-loop.js.map +1 -1
  18. package/dist/index.d.ts +26 -1
  19. package/dist/index.js +26 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/model/communication-logger.d.ts +2 -1
  22. package/dist/model/communication-logger.js +3 -0
  23. package/dist/model/communication-logger.js.map +1 -1
  24. package/dist/model/config.d.ts +10 -4
  25. package/dist/model/config.js +61 -12
  26. package/dist/model/config.js.map +1 -1
  27. package/dist/model/context-window.js +1 -0
  28. package/dist/model/context-window.js.map +1 -1
  29. package/dist/model/deepseek-adapter.d.ts +29 -0
  30. package/dist/model/deepseek-adapter.js +108 -0
  31. package/dist/model/deepseek-adapter.js.map +1 -0
  32. package/dist/model/env.js +35 -19
  33. package/dist/model/env.js.map +1 -1
  34. package/dist/model/kimi-adapter.d.ts +29 -0
  35. package/dist/model/kimi-adapter.js +108 -0
  36. package/dist/model/kimi-adapter.js.map +1 -0
  37. package/dist/model/model-metadata.json +726 -677
  38. package/dist/model/openai-adapter.d.ts +1 -1
  39. package/dist/model/openai-chat-mapper.d.ts +4 -1
  40. package/dist/model/openai-chat-mapper.js +30 -8
  41. package/dist/model/openai-chat-mapper.js.map +1 -1
  42. package/dist/model/openai-mappers.d.ts +5 -2
  43. package/dist/model/openai-mappers.js +33 -6
  44. package/dist/model/openai-mappers.js.map +1 -1
  45. package/dist/model/openai-responses-mapper.d.ts +1 -1
  46. package/dist/model/openai-responses-mapper.js +2 -1
  47. package/dist/model/openai-responses-mapper.js.map +1 -1
  48. package/dist/model/provider-factory.js +32 -0
  49. package/dist/model/provider-factory.js.map +1 -1
  50. package/dist/model/smoke-deepseek-mapper.d.ts +1 -0
  51. package/dist/model/smoke-deepseek-mapper.js +65 -0
  52. package/dist/model/smoke-deepseek-mapper.js.map +1 -0
  53. package/dist/model/smoke-openai.js +1 -1
  54. package/dist/model/smoke-openai.js.map +1 -1
  55. package/dist/model/smoke-responses-mapper.js +6 -6
  56. package/dist/model/smoke-responses-mapper.js.map +1 -1
  57. package/dist/open-directory.d.ts +1 -0
  58. package/dist/open-directory.js +26 -0
  59. package/dist/open-directory.js.map +1 -0
  60. package/dist/paths.d.ts +7 -0
  61. package/dist/paths.js +12 -0
  62. package/dist/paths.js.map +1 -0
  63. package/dist/repl/commands.d.ts +15 -0
  64. package/dist/repl/commands.js +58 -0
  65. package/dist/repl/commands.js.map +1 -1
  66. package/dist/repl/index.js +1012 -171
  67. package/dist/repl/index.js.map +1 -1
  68. package/dist/session/session-export.d.ts +33 -0
  69. package/dist/session/session-export.js +351 -0
  70. package/dist/session/session-export.js.map +1 -0
  71. package/dist/session/session-store.js +2 -2
  72. package/dist/session/session-store.js.map +1 -1
  73. package/dist/session/simple-session-runtime.d.ts +74 -0
  74. package/dist/session/simple-session-runtime.js +171 -0
  75. package/dist/session/simple-session-runtime.js.map +1 -0
  76. package/dist/session/smoke-session.js +22 -1
  77. package/dist/session/smoke-session.js.map +1 -1
  78. package/dist/skills/skill-filesystem.d.ts +32 -0
  79. package/dist/skills/skill-filesystem.js +371 -0
  80. package/dist/skills/skill-filesystem.js.map +1 -0
  81. package/dist/skills/skill-management-tools.d.ts +36 -0
  82. package/dist/skills/skill-management-tools.js +188 -0
  83. package/dist/skills/skill-management-tools.js.map +1 -0
  84. package/dist/skills/skill-tool.d.ts +85 -5
  85. package/dist/skills/skill-tool.js +173 -14
  86. package/dist/skills/skill-tool.js.map +1 -1
  87. package/dist/skills/smoke-skills.js +54 -5
  88. package/dist/skills/smoke-skills.js.map +1 -1
  89. package/dist/tips.d.ts +10 -0
  90. package/dist/tips.js +168 -0
  91. package/dist/tips.js.map +1 -0
  92. package/dist/tools/builtins/image-generation-tool.d.ts +96 -0
  93. package/dist/tools/builtins/image-generation-tool.js +471 -0
  94. package/dist/tools/builtins/image-generation-tool.js.map +1 -0
  95. package/dist/tools/builtins/search-providers.d.ts +15 -1
  96. package/dist/tools/builtins/search-providers.js +195 -1
  97. package/dist/tools/builtins/search-providers.js.map +1 -1
  98. package/dist/tools/builtins/search-tool.js +2 -2
  99. package/dist/tools/builtins/search-tool.js.map +1 -1
  100. package/dist/tools/registry.d.ts +1 -0
  101. package/dist/tools/registry.js +11 -0
  102. package/dist/tools/registry.js.map +1 -1
  103. package/dist/tools/run-tool-use.js +1 -1
  104. package/dist/tools/run-tool-use.js.map +1 -1
  105. package/dist/tools/smoke-tool-system.js +43 -9
  106. package/dist/tools/smoke-tool-system.js.map +1 -1
  107. package/dist/tools/tool.d.ts +9 -1
  108. package/dist/tools/tool.js.map +1 -1
  109. package/dist/types/messages.d.ts +5 -0
  110. package/dist/types/messages.js.map +1 -1
  111. package/dist/ui/display-message.d.ts +103 -0
  112. package/dist/ui/display-message.js +115 -0
  113. package/dist/ui/display-message.js.map +1 -0
  114. package/dist/web/html.d.ts +1 -0
  115. package/dist/web/html.js +862 -0
  116. package/dist/web/html.js.map +1 -0
  117. package/dist/web/index.d.ts +241 -0
  118. package/dist/web/index.js +1873 -0
  119. package/dist/web/index.js.map +1 -0
  120. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from "node:fs/promises";
3
+ import { existsSync, readFileSync } from "node:fs";
3
4
  import path from "node:path";
4
5
  import { stdin, stdout } from "node:process";
5
6
  import React, { useCallback, useEffect, useRef, useState } from "react";
@@ -7,25 +8,29 @@ import { Box, Static, Text, render, useApp, useInput } from "ink";
7
8
  import stripAnsi from "strip-ansi";
8
9
  import wrapAnsi from "wrap-ansi";
9
10
  import { QueryEngine } from "../core/query-engine.js";
10
- import { loadDefaultDotEnvFiles } from "../model/env.js";
11
+ import { getUserDotEnvPath, loadDefaultDotEnvFiles } from "../model/env.js";
11
12
  import { readModelProviderConfig } from "../model/config.js";
12
- import { loadModelCatalog, reasoningEffortsForModel, resolveContextWindowTokens } from "../model/context-window.js";
13
+ import { findModelMetadata, loadModelCatalog, reasoningEffortsForModel, resolveContextWindowTokens } from "../model/context-window.js";
13
14
  import { CommunicationLogger, LoggingModelGateway } from "../model/communication-logger.js";
14
- import { createModelGatewayFromProcessEnv } from "../model/provider-factory.js";
15
+ import { createModelGatewayFromConfig, createModelGatewayFromProcessEnv } from "../model/provider-factory.js";
15
16
  import { ToolRegistry } from "../tools/registry.js";
16
- import { echoTool } from "../tools/builtins/echo-tool.js";
17
17
  import { editTool, writeTool } from "../tools/builtins/edit-tool.js";
18
18
  import { createExecTool } from "../tools/builtins/exec-tool.js";
19
19
  import { listDirectoryTool, readFileTool } from "../tools/builtins/filesystem-tools.js";
20
20
  import { grepTool } from "../tools/builtins/grep-tool.js";
21
21
  import { searchTool } from "../tools/builtins/search-tool.js";
22
22
  import { planTool } from "../tools/builtins/plan-tool.js";
23
+ import { createOpenAIImageGenerationTool } from "../tools/builtins/image-generation-tool.js";
23
24
  import { createAgentTool, resumeAgentTask } from "../agents/agent-tool.js";
24
25
  import { createTaskTools } from "../tasks/task-tools.js";
25
26
  import { TaskStore } from "../tasks/task-store.js";
26
- import { isModelReasoningArgument, isValidReplCommandLine, parseReplCommand, helpText, replCommandDefinitions } from "./commands.js";
27
+ import { cliHelpText, isModelReasoningArgument, isValidReplCommandLine, parseCliReplCommandArgs, parseReplCommand, helpText, replCommandDefinitions } from "./commands.js";
27
28
  import { estimateMarkdownLineCount, markdownRenderKey, MarkdownText } from "./markdown-renderer.js";
29
+ import { writeSessionMarkdownExport } from "../session/session-export.js";
28
30
  import { readClipboard } from "./clipboard.js";
31
+ import { formatTipLine, initialTipIndex, tipAt } from "../tips.js";
32
+ import { openDirectory } from "../open-directory.js";
33
+ import { runWebServer } from "../web/index.js";
29
34
  const e = React.createElement;
30
35
  class SessionUsageTracker {
31
36
  totals = emptyUsageTotals();
@@ -83,14 +88,45 @@ function sumUsageTokens(left, right) {
83
88
  return undefined;
84
89
  return (left ?? 0) + (right ?? 0);
85
90
  }
86
- async function main() {
91
+ async function main(argv = process.argv.slice(2)) {
92
+ const webArgs = parseWebCliArgs(argv);
93
+ if (webArgs) {
94
+ await runWebServer(webArgs);
95
+ return;
96
+ }
97
+ const initialCommand = parseCliReplCommandArgs(argv);
98
+ if (argv.length > 0 && !initialCommand) {
99
+ console.error(`Unknown or incomplete command: ${argv.join(" ")}\n\n${cliHelpText(binaryName())}`);
100
+ process.exitCode = 1;
101
+ return;
102
+ }
103
+ if (initialCommand?.definition.name === "/help") {
104
+ console.log(cliHelpText(binaryName()));
105
+ return;
106
+ }
87
107
  const runtime = await createRuntime();
88
- const instance = render(e(InkRepl, { runtime }), {
108
+ const instance = render(e(InkRepl, { runtime, initialCommandLine: initialCommand?.line }), {
89
109
  exitOnCtrlC: false,
90
110
  });
91
111
  await instance.waitUntilExit();
92
112
  console.log("bye.");
93
113
  }
114
+ function parseWebCliArgs(argv) {
115
+ if (argv.length === 0)
116
+ return undefined;
117
+ const first = argv[0];
118
+ if (first !== "-web" && first !== "--web")
119
+ return undefined;
120
+ return argv.slice(1);
121
+ }
122
+ function binaryName() {
123
+ const arg = process.argv[1];
124
+ if (!arg)
125
+ return "neo";
126
+ const parsed = path.parse(arg);
127
+ const name = parsed.name || "neo";
128
+ return name === "index" ? "neo" : name;
129
+ }
94
130
  function createTaskNotificationSource(taskStore) {
95
131
  return {
96
132
  collectUnnotifiedCompletions() {
@@ -114,7 +150,6 @@ async function createRuntime() {
114
150
  const modelGateway = new LoggingModelGateway(createModelGatewayFromProcessEnv(process.env), communicationLogger);
115
151
  const taskStore = new TaskStore();
116
152
  const tools = new ToolRegistry();
117
- tools.register(echoTool);
118
153
  tools.register(editTool);
119
154
  tools.register(writeTool);
120
155
  tools.register(createExecTool({ taskStore }));
@@ -122,6 +157,8 @@ async function createRuntime() {
122
157
  tools.register(readFileTool);
123
158
  tools.register(grepTool);
124
159
  tools.register(searchTool);
160
+ if (modelConfig?.provider === "openai")
161
+ tools.register(createOpenAIImageGenerationTool());
125
162
  tools.register(planTool);
126
163
  const agentRuntime = { modelGateway, tools, taskStore };
127
164
  tools.register(createAgentTool(agentRuntime));
@@ -145,6 +182,7 @@ async function createRuntime() {
145
182
  modelGateway,
146
183
  tools,
147
184
  taskNotificationSource,
185
+ commands: replCommandDefinitions.map((command) => command.usage),
148
186
  session: {
149
187
  enabled: process.env.AGENT_SESSION_TRANSCRIPT !== "0",
150
188
  sessionId: process.env.AGENT_SESSION_ID,
@@ -156,65 +194,59 @@ async function createRuntime() {
156
194
  },
157
195
  });
158
196
  await engine.initialize();
197
+ const initialMetrics = await engine.contextMetrics();
159
198
  return {
160
199
  engine,
161
200
  communicationLogger,
201
+ modelGateway,
202
+ agentRuntime,
162
203
  usage: new SessionUsageTracker(),
163
204
  taskStore,
164
- initialMetrics: initialContextMetrics(modelConfig?.model, engine.snapshot().messages, tools.names().length),
205
+ tools,
206
+ initialMetrics,
165
207
  defaultReasoning: modelConfig?.defaultReasoning,
208
+ envPath: process.env.NEO_ENV_FILE?.trim() ? path.resolve(process.env.NEO_ENV_FILE.trim()) : envLoad.userDotEnvPath,
166
209
  envNotice: envLoad.createdUserDotEnv ? formatCreatedEnvNotice(envLoad.userDotEnvPath) : undefined,
167
210
  };
168
211
  }
212
+ function syncImageGenerationTool(runtime, provider) {
213
+ runtime.tools.unregister("image2");
214
+ if (provider === "openai")
215
+ runtime.tools.register(createOpenAIImageGenerationTool());
216
+ }
169
217
  function formatCreatedEnvNotice(path) {
170
- return `Created default config file: ${path}\nFill MODEL_API_KEY in that file, then restart neo.`;
218
+ return `Created default config file: ${path}\nSet MODEL_PROVIDER and the matching provider section (for example OPENAI_API_KEY or KIMI_API_KEY), then restart neo.`;
171
219
  }
172
220
  function parseResumeFlag(value) {
173
221
  if (!value)
174
222
  return false;
175
223
  return ["1", "true", "yes", "latest"].includes(value.toLowerCase());
176
224
  }
177
- function initialContextMetrics(model, messageCount, toolCount) {
178
- const window = resolveContextWindowTokens(model);
179
- return {
180
- model,
181
- estimatedInputTokens: 0,
182
- estimatedChars: 0,
183
- messageCount,
184
- toolCount,
185
- contextWindowTokens: window.tokens,
186
- contextWindowSource: window.source,
187
- contextUsageRatio: window.tokens ? 0 : undefined,
188
- modelMetadata: window.model
189
- ? {
190
- id: window.model.id,
191
- provider: window.model.provider,
192
- maxOutputTokens: window.model.maxOutputTokens,
193
- knowledgeCutoff: window.model.knowledgeCutoff,
194
- reasoning: window.model.reasoning,
195
- imageInput: window.model.imageInput,
196
- source: window.model.source,
197
- }
198
- : undefined,
199
- };
225
+ function activeBackgroundTasks(runtime) {
226
+ return runtime.taskStore.list().filter((task) => !runtime.taskStore.isTerminal(task));
227
+ }
228
+ function runningSessionIds(runs) {
229
+ return [...runs.keys()];
200
230
  }
201
- function initialStatus(runtime) {
231
+ function initialStatus(runtime, metrics = runtime.initialMetrics) {
202
232
  return {
203
233
  phase: "ready",
204
234
  metrics: {
205
- ...runtime.initialMetrics,
235
+ ...metrics,
206
236
  messageCount: runtime.engine.snapshot().messages,
207
237
  },
208
238
  streamedOutputTokens: 0,
209
239
  activityTick: 0,
210
240
  };
211
241
  }
212
- function setTerminalTitle(title, dotFilled = true) {
242
+ async function resetStatus(runtime) {
243
+ return initialStatus(runtime, await runtime.engine.contextMetrics());
244
+ }
245
+ function setTerminalTitle(title, prefix = TERMINAL_TITLE_WORKING_PREFIX) {
213
246
  if (!stdout.isTTY)
214
247
  return;
215
248
  const safeTitle = title.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
216
- const dotPrefix = dotFilled ? TERMINAL_TITLE_DOT_FILLED_PREFIX : TERMINAL_TITLE_DOT_BLANK_PREFIX;
217
- const decoratedTitle = `${dotPrefix}${safeTitle || "neo"}`.slice(0, 120);
249
+ const decoratedTitle = `${prefix}${safeTitle || "neo"}`.slice(0, 120);
218
250
  stdout.write(`\u001b]0;${decoratedTitle}\u0007`);
219
251
  }
220
252
  function playReadySound() {
@@ -337,7 +369,7 @@ function pushTextBlock(blocks, text) {
337
369
  function escapeRegExp(value) {
338
370
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
339
371
  }
340
- function InkRepl({ runtime }) {
372
+ function InkRepl({ runtime, initialCommandLine }) {
341
373
  const app = useApp();
342
374
  const lineId = useRef(0);
343
375
  const assistantLineId = useRef(undefined);
@@ -354,13 +386,20 @@ function InkRepl({ runtime }) {
354
386
  const queuedAttachmentsRef = useRef(undefined);
355
387
  const [cursor, setCursor] = useState(0);
356
388
  const [promptPlaceholder, setPromptPlaceholder] = useState(undefined);
389
+ const [tipIndex, setTipIndex] = useState(() => initialTipIndex(runtime.engine.snapshot().session?.sessionId ?? process.cwd()));
357
390
  const [busy, setBusy] = useState(false);
358
391
  const [status, setStatus] = useState(() => initialStatus(runtime));
359
392
  const sessionTitleRef = useRef(sessionTerminalTitle(runtime.engine.snapshot().session));
360
- const [backgroundTaskCount, setBackgroundTaskCount] = useState(() => runtime.taskStore.activeCount());
393
+ const [backgroundTasks, setBackgroundTasks] = useState(() => activeBackgroundTasks(runtime));
394
+ const [backgroundSessionRuns, setBackgroundSessionRuns] = useState([]);
395
+ const backgroundSessionRunsRef = useRef(new Map());
396
+ const suppressReattachedStreamingRef = useRef(new Set());
397
+ const activePromptRunRef = useRef(undefined);
398
+ const foregroundRunTokenRef = useRef(0);
361
399
  const [animationTick, setAnimationTick] = useState(0);
362
- const [terminalTitleDotVisible, setTerminalTitleDotVisible] = useState(true);
363
- const terminalTitleWorking = isActivePhase(status.phase) || backgroundTaskCount > 0;
400
+ const [terminalTitlePrefix, setTerminalTitlePrefix] = useState(TERMINAL_TITLE_READY_PREFIX);
401
+ const backgroundTaskCount = backgroundTasks.length;
402
+ const terminalTitleWorking = isActivePhase(status.phase) || backgroundTaskCount > 0 || backgroundSessionRuns.length > 0;
364
403
  const [sessionsBrowser, setSessionsBrowser] = useState(undefined);
365
404
  const inputRef = useRef(input);
366
405
  const queuedInputRef = useRef(undefined);
@@ -377,6 +416,8 @@ function InkRepl({ runtime }) {
377
416
  const [pasteStatus, setPasteStatus] = useState(undefined);
378
417
  const pasteStatusTimerRef = useRef(undefined);
379
418
  const [slashCompletionIndex, setSlashCompletionIndex] = useState(0);
419
+ const [loginForm, setLoginForm] = useState(undefined);
420
+ const loginFormRef = useRef(undefined);
380
421
  useEffect(() => {
381
422
  enableTerminalFocusReporting();
382
423
  enableTerminalMouseReporting();
@@ -386,36 +427,35 @@ function InkRepl({ runtime }) {
386
427
  };
387
428
  }, []);
388
429
  useEffect(() => {
389
- if (!busy && backgroundTaskCount === 0)
430
+ if (!busy && backgroundTaskCount === 0 && backgroundSessionRuns.length === 0)
390
431
  return undefined;
391
432
  const interval = setInterval(() => setAnimationTick((current) => current + 1), REPL_ANIMATION_INTERVAL_MS);
392
433
  return () => clearInterval(interval);
393
- }, [busy, backgroundTaskCount]);
434
+ }, [busy, backgroundTaskCount, backgroundSessionRuns.length]);
394
435
  useEffect(() => {
395
- const updateBackgroundTaskCount = () => setBackgroundTaskCount(runtime.taskStore.activeCount());
396
- updateBackgroundTaskCount();
397
- return runtime.taskStore.subscribe(updateBackgroundTaskCount);
436
+ const updateBackgroundTasks = () => setBackgroundTasks(activeBackgroundTasks(runtime));
437
+ updateBackgroundTasks();
438
+ return runtime.taskStore.subscribe(updateBackgroundTasks);
398
439
  }, [runtime]);
399
440
  useEffect(() => {
400
441
  if (!terminalTitleWorking) {
401
- setTerminalTitleDotVisible(true);
442
+ setTerminalTitlePrefix(TERMINAL_TITLE_READY_PREFIX);
402
443
  return undefined;
403
444
  }
404
- setTerminalTitleDotVisible(true);
405
- const interval = setInterval(() => setTerminalTitleDotVisible((visible) => !visible), TERMINAL_TITLE_BLINK_INTERVAL_MS);
406
- return () => clearInterval(interval);
445
+ setTerminalTitlePrefix(TERMINAL_TITLE_WORKING_PREFIX);
446
+ return undefined;
407
447
  }, [terminalTitleWorking]);
408
448
  useEffect(() => {
409
449
  const updateTitle = (snapshot) => {
410
450
  sessionTitleRef.current = sessionTerminalTitle(snapshot);
411
- setTerminalTitle(sessionTitleRef.current, terminalTitleDotVisible);
451
+ setTerminalTitle(sessionTitleRef.current, terminalTitlePrefix);
412
452
  };
413
453
  updateTitle(runtime.engine.snapshot().session);
414
454
  return runtime.engine.onSessionTitleChange(updateTitle);
415
- }, [runtime, terminalTitleDotVisible]);
455
+ }, [runtime, terminalTitlePrefix]);
416
456
  useEffect(() => {
417
- setTerminalTitle(sessionTitleRef.current, terminalTitleDotVisible);
418
- }, [terminalTitleDotVisible]);
457
+ setTerminalTitle(sessionTitleRef.current, terminalTitlePrefix);
458
+ }, [terminalTitlePrefix]);
419
459
  const setPromptState = (text, nextCursor, options) => {
420
460
  const safeCursor = Math.max(0, Math.min(nextCursor, text.length));
421
461
  inputRef.current = text;
@@ -442,6 +482,10 @@ function InkRepl({ runtime }) {
442
482
  setSlashCompletionIndex(safeIndex);
443
483
  };
444
484
  const resetSlashCompletionSelection = () => setSlashCompletionSelection(0);
485
+ const setLoginFormState = (next) => {
486
+ loginFormRef.current = next;
487
+ setLoginForm(next);
488
+ };
445
489
  const syncAttachmentsForText = (text) => {
446
490
  const next = attachmentsRef.current.filter((attachment) => text.includes(attachment.label));
447
491
  if (next.length === attachmentsRef.current.length)
@@ -468,9 +512,11 @@ function InkRepl({ runtime }) {
468
512
  }, PASTE_STATUS_DISPLAY_MS);
469
513
  pasteStatusTimerRef.current = timer;
470
514
  };
515
+ const advanceTip = () => setTipIndex((current) => current + 1);
471
516
  const insertAtCursor = (value) => {
472
517
  const currentText = inputRef.current;
473
518
  const currentCursor = cursorRef.current;
519
+ advanceTip();
474
520
  setPromptState(`${currentText.slice(0, currentCursor)}${value}${currentText.slice(currentCursor)}`, currentCursor + value.length);
475
521
  };
476
522
  const insertAttachmentLabel = (attachment) => {
@@ -509,6 +555,22 @@ function InkRepl({ runtime }) {
509
555
  busyRef.current = next;
510
556
  setBusy(next);
511
557
  };
558
+ const stopForegroundRun = (reason) => {
559
+ const controller = activeAbortController.current;
560
+ const runWasActive = busyRef.current || Boolean(controller && !controller.signal.aborted);
561
+ foregroundRunTokenRef.current += 1;
562
+ activePromptRunRef.current = undefined;
563
+ runtime.usage.reset();
564
+ if (controller && !controller.signal.aborted)
565
+ controller.abort(reason);
566
+ activeAbortController.current = undefined;
567
+ interruptArmed.current = false;
568
+ setQueuedPromptState(undefined);
569
+ finalizeForegroundView();
570
+ setBusyState(false);
571
+ setStatus((current) => ({ ...current, phase: "ready", detail: undefined, inputTokenUpdatedAt: undefined, outputTokenUpdatedAt: undefined, retryCooldownUntil: undefined }));
572
+ return runWasActive;
573
+ };
512
574
  const append = (line) => {
513
575
  const id = ++lineId.current;
514
576
  const next = { id, ...line };
@@ -538,17 +600,73 @@ function InkRepl({ runtime }) {
538
600
  const replaceLine = (id, patch) => {
539
601
  setLines((current) => current.map((line) => line.id === id ? { ...line, ...patch, renderedKey: undefined } : line));
540
602
  };
541
- const resumeSnapshot = (snapshot) => {
603
+ const syncBackgroundSessionRuns = () => {
604
+ setBackgroundSessionRuns([...backgroundSessionRunsRef.current.values()]);
605
+ };
606
+ const detachRunningForeground = (reason) => {
607
+ if (!busyRef.current)
608
+ return false;
609
+ const snapshot = runtime.engine.snapshot().session;
610
+ const sessionId = snapshot?.sessionId ?? `session-${Date.now().toString(36)}`;
611
+ const run = activePromptRunRef.current;
612
+ if (run && !backgroundSessionRunsRef.current.has(sessionId)) {
613
+ const backgroundRun = {
614
+ sessionId,
615
+ title: snapshot?.title,
616
+ reason,
617
+ startedAt: Date.now(),
618
+ engine: runtime.engine,
619
+ abortController: activeAbortController.current ?? new AbortController(),
620
+ promise: run,
621
+ };
622
+ backgroundSessionRunsRef.current.set(sessionId, backgroundRun);
623
+ syncBackgroundSessionRuns();
624
+ setSessionsBrowser((current) => current ? { ...current, runningSessionIds: runningSessionIds(backgroundSessionRunsRef.current) } : current);
625
+ run.finally(() => {
626
+ backgroundSessionRunsRef.current.delete(sessionId);
627
+ suppressReattachedStreamingRef.current.delete(backgroundRun.engine);
628
+ syncBackgroundSessionRuns();
629
+ setSessionsBrowser((current) => current ? { ...current, runningSessionIds: runningSessionIds(backgroundSessionRunsRef.current) } : current);
630
+ }).catch(() => undefined);
631
+ }
632
+ activeAbortController.current = undefined;
633
+ interruptArmed.current = false;
634
+ setQueuedPromptState(undefined);
635
+ setBusyState(false);
636
+ setStatus((current) => ({ ...current, phase: "ready", detail: undefined }));
637
+ append(systemLine(`Detached running ${sessionId} to background for ${reason}.`));
638
+ return true;
639
+ };
640
+ const resetForegroundView = (metrics) => {
542
641
  runtime.usage.reset();
543
- setStatus(initialStatus(runtime));
642
+ setStatus(initialStatus(runtime, metrics));
544
643
  resetLinesToHistory(runtime, setLines, lineId);
545
644
  assistantLineId.current = undefined;
546
645
  thinkingLineId.current = undefined;
547
646
  finalizedThinkingLineId.current = undefined;
548
647
  toolLineIds.current.clear();
549
648
  clearPendingToolResultTimers();
649
+ };
650
+ const resumeSnapshot = (snapshot, metrics) => {
651
+ resetForegroundView(metrics);
550
652
  append(systemLine(formatResume(snapshot)));
551
653
  };
654
+ const reattachRunningSession = async (run) => {
655
+ detachRunningForeground("session switch");
656
+ backgroundSessionRunsRef.current.delete(run.sessionId);
657
+ syncBackgroundSessionRuns();
658
+ setSessionsBrowser((current) => current ? { ...current, runningSessionIds: runningSessionIds(backgroundSessionRunsRef.current) } : current);
659
+ runtime.engine = run.engine;
660
+ activeAbortController.current = run.abortController;
661
+ interruptArmed.current = false;
662
+ activePromptRunRef.current = run.promise;
663
+ suppressReattachedStreamingRef.current.add(run.engine);
664
+ const metrics = await runtime.engine.contextMetrics();
665
+ resetForegroundView(metrics);
666
+ setBusyState(true);
667
+ setStatus((current) => ({ ...current, phase: "running", detail: "working" }));
668
+ append(systemLine(`reattached running session ${run.sessionId}`));
669
+ };
552
670
  const finalizeLiveLine = (id) => {
553
671
  if (id === undefined)
554
672
  return;
@@ -599,6 +717,13 @@ function InkRepl({ runtime }) {
599
717
  finalizeToolLine(id);
600
718
  toolLineIds.current.clear();
601
719
  };
720
+ const finalizeForegroundView = () => {
721
+ finalizeLiveLine(assistantLineId.current);
722
+ finalizeThinkingLine();
723
+ finalizeActiveToolLines();
724
+ assistantLineId.current = undefined;
725
+ finalizedThinkingLineId.current = undefined;
726
+ };
602
727
  const handleEvent = (event) => {
603
728
  setStatus((current) => reduceStatus(current, event));
604
729
  if (event.type === "usage")
@@ -693,14 +818,10 @@ function InkRepl({ runtime }) {
693
818
  const trimmed = text.trim();
694
819
  if (!trimmed)
695
820
  return;
696
- if (busyRef.current) {
697
- if (queuedInputRef.current !== undefined)
698
- return;
699
- setQueuedPromptState(text, submitAttachments);
700
- setHistorySelection(undefined);
701
- setPromptState("", 0);
702
- clearAttachments();
703
- return;
821
+ const command = parseReplCommand(text);
822
+ const detachedForCommand = busyRef.current && (command.type === "new" || command.type === "sessions");
823
+ if (busyRef.current && !detachedForCommand) {
824
+ stopForegroundRun("Interrupted by new prompt");
704
825
  }
705
826
  history.current = [text, ...history.current.filter((entry) => entry !== text)].slice(0, 100);
706
827
  setHistorySelection(undefined);
@@ -740,6 +861,7 @@ function InkRepl({ runtime }) {
740
861
  return;
741
862
  }
742
863
  if (command.type === "compact") {
864
+ const runToken = ++foregroundRunTokenRef.current;
743
865
  const abortController = new AbortController();
744
866
  activeAbortController.current = abortController;
745
867
  interruptArmed.current = false;
@@ -747,27 +869,31 @@ function InkRepl({ runtime }) {
747
869
  setStatus((current) => ({ ...current, phase: "compacting", detail: "manual compact", activityTick: current.activityTick + 1 }));
748
870
  try {
749
871
  const result = await runtime.engine.compact({ abortSignal: abortController.signal });
872
+ if (foregroundRunTokenRef.current !== runToken)
873
+ return;
750
874
  const metrics = await runtime.engine.contextMetrics();
875
+ if (foregroundRunTokenRef.current !== runToken)
876
+ return;
751
877
  append(systemLine(formatManualCompaction(result)));
752
878
  setStatus((current) => reduceStatus(current, { type: "context.metrics", metrics }));
753
879
  }
754
880
  catch (error) {
755
- append({ kind: "error", text: error instanceof Error ? error.message : String(error) });
881
+ if (foregroundRunTokenRef.current === runToken)
882
+ append({ kind: "error", text: error instanceof Error ? error.message : String(error) });
756
883
  }
757
884
  finally {
885
+ if (foregroundRunTokenRef.current !== runToken)
886
+ return;
758
887
  if (activeAbortController.current === abortController)
759
888
  activeAbortController.current = undefined;
760
889
  interruptArmed.current = false;
761
890
  setBusyState(false);
762
891
  setStatus((current) => ({ ...current, phase: "ready", detail: undefined, activityTick: current.activityTick + 1 }));
763
- const queued = takeQueuedPromptState();
764
- if (queued !== undefined) {
765
- void submitLine(queued.text, queued.attachments);
766
- }
767
892
  }
768
893
  return;
769
894
  }
770
895
  if (command.type === "pure") {
896
+ const runToken = ++foregroundRunTokenRef.current;
771
897
  const abortController = new AbortController();
772
898
  activeAbortController.current = abortController;
773
899
  interruptArmed.current = false;
@@ -775,39 +901,95 @@ function InkRepl({ runtime }) {
775
901
  setStatus((current) => ({ ...current, phase: "compacting", detail: "pure compact", activityTick: current.activityTick + 1 }));
776
902
  try {
777
903
  const result = await runtime.engine.pureCompact({ abortSignal: abortController.signal });
904
+ if (foregroundRunTokenRef.current !== runToken)
905
+ return;
778
906
  const metrics = await runtime.engine.contextMetrics();
907
+ if (foregroundRunTokenRef.current !== runToken)
908
+ return;
779
909
  append(systemLine(formatPureCompaction(result)));
780
910
  setStatus((current) => reduceStatus(current, { type: "context.metrics", metrics }));
781
911
  }
782
912
  catch (error) {
783
- append({ kind: "error", text: error instanceof Error ? error.message : String(error) });
913
+ if (foregroundRunTokenRef.current === runToken)
914
+ append({ kind: "error", text: error instanceof Error ? error.message : String(error) });
784
915
  }
785
916
  finally {
917
+ if (foregroundRunTokenRef.current !== runToken)
918
+ return;
786
919
  if (activeAbortController.current === abortController)
787
920
  activeAbortController.current = undefined;
788
921
  interruptArmed.current = false;
789
922
  setBusyState(false);
790
923
  setStatus((current) => ({ ...current, phase: "ready", detail: undefined, activityTick: current.activityTick + 1 }));
791
- const queued = takeQueuedPromptState();
792
- if (queued !== undefined) {
793
- void submitLine(queued.text, queued.attachments);
794
- }
795
924
  }
796
925
  return;
797
926
  }
798
927
  if (command.type === "reset") {
799
928
  runtime.engine.reset();
800
929
  runtime.usage.reset();
801
- setStatus(initialStatus(runtime));
930
+ setStatus(await resetStatus(runtime));
802
931
  append(systemLine("transcript reset"));
803
932
  return;
804
933
  }
805
934
  if (command.type === "state") {
806
- append(systemLine(formatReplData({ ...runtime.engine.snapshot(), communicationLog: runtime.communicationLogger.snapshot() }, 12000), EXPANDED_SUMMARY_MAX_LINES));
935
+ const contextMetrics = await runtime.engine.contextMetrics();
936
+ append(systemLine(formatReplData({ ...runtime.engine.snapshot(), contextMetrics, communicationLog: runtime.communicationLogger.snapshot() }, 12000), EXPANDED_SUMMARY_MAX_LINES));
937
+ return;
938
+ }
939
+ if (command.type === "export") {
940
+ setBusyState(true);
941
+ setStatus((current) => ({ ...current, phase: "running", detail: "exporting session", activityTick: current.activityTick + 1 }));
942
+ try {
943
+ const line = await handleExportCommand(command, runtime);
944
+ append(line);
945
+ }
946
+ catch (error) {
947
+ append({ kind: "error", text: error instanceof Error ? error.message : String(error) });
948
+ }
949
+ finally {
950
+ setBusyState(false);
951
+ setStatus((current) => ({ ...current, phase: "ready", detail: undefined, activityTick: current.activityTick + 1 }));
952
+ }
953
+ return;
954
+ }
955
+ if (command.type === "env") {
956
+ const envDirectory = path.dirname(runtime.envPath);
957
+ try {
958
+ await fs.mkdir(envDirectory, { recursive: true });
959
+ await openDirectory(envDirectory);
960
+ append({ kind: "system", title: "System", text: `Opened env directory: ${envDirectory}`, format: "plain", previewStyle: "summary" });
961
+ }
962
+ catch (error) {
963
+ append({ kind: "error", text: `Failed to open env directory ${envDirectory}: ${error instanceof Error ? error.message : String(error)}`, format: "plain" });
964
+ }
965
+ return;
966
+ }
967
+ if (command.type === "new") {
968
+ detachRunningForeground("new session");
969
+ runtime.engine = runtime.engine.forkForSession(undefined, false);
970
+ await runtime.engine.initialize();
971
+ const snapshot = runtime.engine.snapshot().session;
972
+ const metrics = await runtime.engine.contextMetrics();
973
+ runtime.usage.reset();
974
+ setStatus(initialStatus(runtime, metrics));
975
+ resetLinesToHistory(runtime, setLines, lineId);
976
+ assistantLineId.current = undefined;
977
+ thinkingLineId.current = undefined;
978
+ finalizedThinkingLineId.current = undefined;
979
+ toolLineIds.current.clear();
980
+ clearPendingToolResultTimers();
981
+ append(systemLine(snapshot ? `new session ${snapshot.sessionId}` : "new session"));
807
982
  return;
808
983
  }
809
984
  if (command.type === "sessions") {
810
- await handleSessionsCommand(runtime, setSessionsBrowser, (line) => append(line));
985
+ detachRunningForeground("session browser");
986
+ await handleSessionsCommand(runtime, runningSessionIds(backgroundSessionRunsRef.current), setSessionsBrowser, (line) => append(line));
987
+ return;
988
+ }
989
+ if (command.type === "login") {
990
+ setSessionsBrowser(undefined);
991
+ setLoginFormState(createLoginFormState(runtime.envPath));
992
+ append(systemLine("Opening provider login. Use ↑/↓ to choose, Enter to continue/save, Esc to cancel."));
811
993
  return;
812
994
  }
813
995
  if (command.type === "log") {
@@ -815,9 +997,23 @@ function InkRepl({ runtime }) {
815
997
  return;
816
998
  }
817
999
  if (command.type === "model") {
818
- const line = handleModelCommand(command, runtime);
819
- setStatus((current) => ({ ...current, metrics: { ...initialContextMetrics(runtime.engine.getModelSettings().model, runtime.engine.snapshot().messages, runtime.initialMetrics.toolCount), messageCount: runtime.engine.snapshot().messages } }));
820
- append(line);
1000
+ setBusyState(true);
1001
+ setStatus((current) => ({ ...current, phase: "running", detail: "saving model settings", activityTick: current.activityTick + 1 }));
1002
+ try {
1003
+ const line = await handleModelCommand(command, runtime);
1004
+ const metrics = await runtime.engine.contextMetrics();
1005
+ setStatus((current) => ({
1006
+ ...current,
1007
+ phase: "ready",
1008
+ detail: undefined,
1009
+ metrics,
1010
+ activityTick: current.activityTick + 1,
1011
+ }));
1012
+ append(line);
1013
+ }
1014
+ finally {
1015
+ setBusyState(false);
1016
+ }
821
1017
  return;
822
1018
  }
823
1019
  if (text.trimStart().startsWith("/")) {
@@ -826,6 +1022,7 @@ function InkRepl({ runtime }) {
826
1022
  }
827
1023
  const promptPayload = buildPromptPayload(command.text, submitAttachments);
828
1024
  append({ kind: "user", text });
1025
+ const runToken = ++foregroundRunTokenRef.current;
829
1026
  const abortController = new AbortController();
830
1027
  activeAbortController.current = abortController;
831
1028
  interruptArmed.current = false;
@@ -840,28 +1037,45 @@ function InkRepl({ runtime }) {
840
1037
  outputTokenUpdatedAt: undefined,
841
1038
  retryCooldownUntil: undefined,
842
1039
  }));
843
- try {
844
- for await (const event of runtime.engine.sendUserText(promptPayload.text, { abortSignal: abortController.signal, blocks: promptPayload.blocks, displayText: text })) {
1040
+ const engine = runtime.engine;
1041
+ const run = (async () => {
1042
+ for await (const event of engine.sendUserText(promptPayload.text, { abortSignal: abortController.signal, blocks: promptPayload.blocks, displayText: text })) {
1043
+ if (foregroundRunTokenRef.current !== runToken)
1044
+ continue;
1045
+ if (runtime.engine !== engine)
1046
+ continue;
1047
+ if (suppressReattachedStreamingRef.current.has(engine)) {
1048
+ if (event.type === "message" || event.type === "terminal" || event.type === "error" || event.type === "context.metrics" || event.type === "usage") {
1049
+ if (event.type === "message" || event.type === "terminal" || event.type === "error")
1050
+ suppressReattachedStreamingRef.current.delete(engine);
1051
+ handleEvent(event);
1052
+ }
1053
+ continue;
1054
+ }
845
1055
  handleEvent(event);
846
1056
  }
1057
+ })();
1058
+ activePromptRunRef.current = run;
1059
+ try {
1060
+ await run;
847
1061
  }
848
1062
  catch (error) {
849
- finalizeLiveLine(assistantLineId.current);
850
- finalizeThinkingLine();
851
- finalizeActiveToolLines();
852
- assistantLineId.current = undefined;
853
- finalizedThinkingLineId.current = undefined;
854
- append({ kind: "error", text: error instanceof Error ? error.message : String(error) });
1063
+ if (foregroundRunTokenRef.current === runToken && runtime.engine === engine) {
1064
+ finalizeForegroundView();
1065
+ append({ kind: "error", text: error instanceof Error ? error.message : String(error) });
1066
+ }
855
1067
  }
856
1068
  finally {
1069
+ if (activePromptRunRef.current === run)
1070
+ activePromptRunRef.current = undefined;
1071
+ if (foregroundRunTokenRef.current !== runToken)
1072
+ return;
1073
+ if (runtime.engine !== engine)
1074
+ return;
857
1075
  if (activeAbortController.current === abortController)
858
1076
  activeAbortController.current = undefined;
859
1077
  interruptArmed.current = false;
860
- finalizeLiveLine(assistantLineId.current);
861
- finalizeThinkingLine();
862
- finalizeActiveToolLines();
863
- assistantLineId.current = undefined;
864
- finalizedThinkingLineId.current = undefined;
1078
+ finalizeForegroundView();
865
1079
  setBusyState(false);
866
1080
  setStatus((current) => ({
867
1081
  ...current,
@@ -873,13 +1087,10 @@ function InkRepl({ runtime }) {
873
1087
  }));
874
1088
  if (!terminalFocusedRef.current)
875
1089
  playReadySound();
876
- const queued = takeQueuedPromptState();
877
- if (queued !== undefined) {
878
- void submitLine(queued.text, queued.attachments);
879
- }
880
1090
  }
881
1091
  };
882
1092
  useEffect(() => {
1093
+ setTipIndex(initialTipIndex(runtime.engine.snapshot().session?.sessionId ?? process.cwd()));
883
1094
  setLines(initialLines(runtime, lineId));
884
1095
  assistantLineId.current = undefined;
885
1096
  thinkingLineId.current = undefined;
@@ -888,16 +1099,26 @@ function InkRepl({ runtime }) {
888
1099
  clearPendingToolResultTimers();
889
1100
  setStatus(initialStatus(runtime));
890
1101
  setSessionsBrowser(undefined);
1102
+ setLoginFormState(undefined);
891
1103
  setQueuedPromptState(undefined);
892
1104
  setPromptState("", 0);
893
1105
  }, [runtime]);
1106
+ useEffect(() => {
1107
+ if (initialCommandLine === undefined)
1108
+ return;
1109
+ void submitLine(initialCommandLine);
1110
+ }, []);
894
1111
  const terminalSize = useTerminalSize();
895
1112
  const width = terminalSize.columns;
896
1113
  const inputLockedByQueue = busy && queuedInput !== undefined;
897
1114
  const prompt = promptPrefix(busy);
898
- const promptDisplayText = input.length === 0 && promptPlaceholder ? promptPlaceholder : input;
899
- const promptDisplayCursor = input.length === 0 && promptPlaceholder ? promptPlaceholder.length : cursor;
900
- const slashCompletions = inputLockedByQueue || promptPlaceholder ? [] : slashCommandCompletions(input, cursor);
1115
+ const currentTip = tipAt(tipIndex);
1116
+ const activePlaceholder = input.length === 0 ? promptPlaceholder ?? currentTip.placeholder : undefined;
1117
+ const promptDisplayText = input;
1118
+ const promptDisplayCursor = cursor;
1119
+ const promptLayoutText = activePlaceholder ? ` ${activePlaceholder}` : promptDisplayText;
1120
+ const promptLayoutCursor = activePlaceholder ? 0 : promptDisplayCursor;
1121
+ const slashCompletions = inputLockedByQueue || (input.length === 0 && promptPlaceholder !== undefined) || loginForm ? [] : slashCommandCompletions(input, cursor);
901
1122
  const visibleSlashCompletionCount = slashCompletions.length;
902
1123
  const selectedSlashCompletionIndex = visibleSlashCompletionCount === 0
903
1124
  ? 0
@@ -905,7 +1126,7 @@ function InkRepl({ runtime }) {
905
1126
  if (selectedSlashCompletionIndex !== slashCompletionIndexRef.current) {
906
1127
  slashCompletionIndexRef.current = selectedSlashCompletionIndex;
907
1128
  }
908
- const promptHeight = promptTextView(promptDisplayText, promptDisplayCursor, width, prompt).length + slashCompletionViewHeight(slashCompletions) + (queuedInput !== undefined ? QUEUED_INPUT_RENDER_ROWS : 0) + (pasteStatus ? 1 : 0);
1129
+ const promptHeight = promptTextView(promptLayoutText, promptLayoutCursor, width, prompt).length + slashCompletionViewHeight(slashCompletions) + (queuedInput !== undefined ? QUEUED_INPUT_RENDER_ROWS : 0) + (pasteStatus ? 1 : 0);
909
1130
  const firstDynamicLineIndex = lines.findIndex((line) => lineNeedsDynamicRender(line, messageContentWidth(width)));
910
1131
  const staticLines = firstDynamicLineIndex === -1 ? lines : lines.slice(0, firstDynamicLineIndex);
911
1132
  const dynamicLines = firstDynamicLineIndex === -1 ? [] : lines.slice(firstDynamicLineIndex);
@@ -913,9 +1134,10 @@ function InkRepl({ runtime }) {
913
1134
  const blockIndex = staticLines.length + i;
914
1135
  return sum + (blockIndex > 0 ? MESSAGE_BLOCK_SPACING_LINES : 0);
915
1136
  }, 0);
916
- const statusRenderRows = STATUS_BAR_RENDER_ROWS + (backgroundTaskCount > 0 ? BACKGROUND_TASK_STATUS_RENDER_ROWS : 0);
1137
+ const statusRenderRows = STATUS_BAR_RENDER_ROWS + backgroundTaskStatusRenderRows(backgroundTasks.length);
917
1138
  const sessionsBrowserHeight = sessionsBrowser ? sessionsBrowserViewHeight(sessionsBrowser) : 0;
918
- const liveViewportLines = Math.max(MIN_LIVE_VIEWPORT_LINES, terminalSize.rows - promptHeight - statusRenderRows - sessionsBrowserHeight - dynamicMarginOverhead - 1);
1139
+ const loginFormHeight = loginForm ? loginFormViewHeight(loginForm) : 0;
1140
+ const liveViewportLines = Math.max(MIN_LIVE_VIEWPORT_LINES, terminalSize.rows - promptHeight - statusRenderRows - sessionsBrowserHeight - loginFormHeight - dynamicMarginOverhead - 1);
919
1141
  useInput((value, key) => {
920
1142
  if (isTerminalFocusInSequence(value)) {
921
1143
  terminalFocusedRef.current = true;
@@ -946,12 +1168,7 @@ function InkRepl({ runtime }) {
946
1168
  setPromptPlaceholder(EMPTY_CTRL_C_EXIT_PLACEHOLDER);
947
1169
  resetSlashCompletionSelection();
948
1170
  if (busyRef.current) {
949
- const controller = activeAbortController.current;
950
- if (controller && !controller.signal.aborted && !interruptArmed.current) {
951
- interruptArmed.current = true;
952
- controller.abort("Interrupted by Ctrl+C");
953
- setStatus((current) => ({ ...current, phase: "stopped", detail: "interrupt requested" }));
954
- }
1171
+ stopForegroundRun("Interrupted by Ctrl+C");
955
1172
  }
956
1173
  return;
957
1174
  }
@@ -963,6 +1180,10 @@ function InkRepl({ runtime }) {
963
1180
  restoreQueuedPromptToEditor();
964
1181
  return;
965
1182
  }
1183
+ if (loginFormRef.current) {
1184
+ handleLoginFormInput(value, key, loginFormRef.current, setLoginFormState, runtime, append, setStatus);
1185
+ return;
1186
+ }
966
1187
  if (sessionsBrowser) {
967
1188
  if (key.escape) {
968
1189
  setSessionsBrowser(undefined);
@@ -988,10 +1209,17 @@ function InkRepl({ runtime }) {
988
1209
  const selected = sessionsBrowser.sessions[sessionAbsoluteIndex(sessionsBrowser)];
989
1210
  if (selected) {
990
1211
  setSessionsBrowser(undefined);
991
- void handleResumeCommand(selected.sessionId, runtime, (line) => append(line)).then((resumed) => {
992
- if (resumed)
993
- resumeSnapshot(resumed);
994
- });
1212
+ const running = backgroundSessionRunsRef.current.get(selected.sessionId);
1213
+ if (running) {
1214
+ void reattachRunningSession(running);
1215
+ }
1216
+ else {
1217
+ detachRunningForeground("session switch");
1218
+ void handleResumeCommand(selected.sessionId, runtime, (line) => append(line)).then((result) => {
1219
+ if (result)
1220
+ resumeSnapshot(result.snapshot, result.metrics);
1221
+ });
1222
+ }
995
1223
  }
996
1224
  return;
997
1225
  }
@@ -1023,6 +1251,10 @@ function InkRepl({ runtime }) {
1023
1251
  if (key.backspace || key.delete) {
1024
1252
  const currentText = inputRef.current;
1025
1253
  const currentCursor = cursorRef.current;
1254
+ if (currentText.length === 0) {
1255
+ setTipIndex((current) => current + 1);
1256
+ return;
1257
+ }
1026
1258
  if (currentCursor > 0) {
1027
1259
  setPromptState(`${currentText.slice(0, currentCursor - 1)}${currentText.slice(currentCursor)}`, currentCursor - 1);
1028
1260
  }
@@ -1034,6 +1266,10 @@ function InkRepl({ runtime }) {
1034
1266
  setSlashCompletionSelection((slashCompletionIndexRef.current + completionCount - SLASH_COMPLETION_PAGE_SIZE) % completionCount);
1035
1267
  return;
1036
1268
  }
1269
+ if (inputRef.current.length === 0) {
1270
+ setTipIndex((current) => current - 1);
1271
+ return;
1272
+ }
1037
1273
  setPromptState(inputRef.current, cursorRef.current - 1);
1038
1274
  return;
1039
1275
  }
@@ -1043,18 +1279,32 @@ function InkRepl({ runtime }) {
1043
1279
  setSlashCompletionSelection((slashCompletionIndexRef.current + SLASH_COMPLETION_PAGE_SIZE) % completionCount);
1044
1280
  return;
1045
1281
  }
1282
+ if (inputRef.current.length === 0) {
1283
+ setTipIndex((current) => current + 1);
1284
+ return;
1285
+ }
1046
1286
  setPromptState(inputRef.current, cursorRef.current + 1);
1047
1287
  return;
1048
1288
  }
1049
1289
  if (key.home) {
1050
- setPromptState(inputRef.current, 0);
1290
+ if (inputRef.current.length === 0)
1291
+ setTipIndex(0);
1292
+ else
1293
+ setPromptState(inputRef.current, 0);
1051
1294
  return;
1052
1295
  }
1053
1296
  if (key.end) {
1054
- setPromptState(inputRef.current, inputRef.current.length);
1297
+ if (inputRef.current.length === 0)
1298
+ setTipIndex((current) => current + 1);
1299
+ else
1300
+ setPromptState(inputRef.current, inputRef.current.length);
1055
1301
  return;
1056
1302
  }
1057
1303
  if (key.upArrow) {
1304
+ if (inputRef.current.length === 0 && history.current.length === 0) {
1305
+ setTipIndex((current) => current - 1);
1306
+ return;
1307
+ }
1058
1308
  const completionCount = slashCompletionSelectableCount(inputRef.current, cursorRef.current);
1059
1309
  if (completionCount > 0) {
1060
1310
  setSlashCompletionSelection((slashCompletionIndexRef.current + completionCount - 1) % completionCount);
@@ -1068,6 +1318,10 @@ function InkRepl({ runtime }) {
1068
1318
  return;
1069
1319
  }
1070
1320
  if (key.downArrow) {
1321
+ if (inputRef.current.length === 0 && historyIndexRef.current === undefined) {
1322
+ setTipIndex((current) => current + 1);
1323
+ return;
1324
+ }
1071
1325
  const completionCount = slashCompletionSelectableCount(inputRef.current, cursorRef.current);
1072
1326
  if (completionCount > 0) {
1073
1327
  setSlashCompletionSelection((slashCompletionIndexRef.current + 1) % completionCount);
@@ -1089,6 +1343,10 @@ function InkRepl({ runtime }) {
1089
1343
  }
1090
1344
  if (key.tab) {
1091
1345
  const currentText = inputRef.current;
1346
+ if (currentText.length === 0) {
1347
+ setTipIndex((current) => current + 1);
1348
+ return;
1349
+ }
1092
1350
  const currentCursor = cursorRef.current;
1093
1351
  const completions = slashCommandCompletions(currentText, currentCursor);
1094
1352
  const completion = completions[Math.min(slashCompletionIndexRef.current, completions.length - 1)];
@@ -1100,9 +1358,10 @@ function InkRepl({ runtime }) {
1100
1358
  }
1101
1359
  if (value && !key.ctrl && !key.meta) {
1102
1360
  insertAtCursor(value);
1361
+ return;
1103
1362
  }
1104
1363
  });
1105
- return e(Box, { flexDirection: "column" }, e((Static), { items: staticLines, children: (line, index) => e(MessageBlock, { key: line.id, line, width, blockIndex: index }) }), e(MessageList, { lines: dynamicLines, width, liveMaxLines: liveViewportLines, lineIndexOffset: staticLines.length, onMarkdownRenderComplete: markLineRendered }), sessionsBrowser ? e(SessionsBrowser, { state: sessionsBrowser, width }) : null, e(StatusBar, { status, animationTick, width }), backgroundTaskCount > 0 ? e(BackgroundTaskStatusLine, { count: backgroundTaskCount, width }) : null, pasteStatus ? e(PasteStatusLine, { text: pasteStatus, width }) : null, queuedInput !== undefined ? e(QueuedInputLine, { text: queuedInput, width }) : null, e(PromptLine, { text: promptDisplayText, cursor: promptDisplayCursor, busy, locked: inputLockedByQueue, placeholder: input.length === 0 && promptPlaceholder !== undefined, width, prompt, slashCompletions, selectedSlashCompletionIndex, attachments }));
1364
+ return e(Box, { flexDirection: "column" }, e((Static), { items: staticLines, children: (line, index) => e(MessageBlock, { key: line.id, line, width, blockIndex: index }) }), e(MessageList, { lines: dynamicLines, width, liveMaxLines: liveViewportLines, lineIndexOffset: staticLines.length, onMarkdownRenderComplete: markLineRendered }), sessionsBrowser ? e(SessionsBrowser, { state: sessionsBrowser, width }) : null, loginForm ? e(LoginFormView, { state: loginForm, width }) : null, e(StatusBar, { status, animationTick, width }), backgroundTasks.length > 0 ? e(BackgroundTaskStatusLine, { tasks: backgroundTasks, width }) : null, pasteStatus ? e(PasteStatusLine, { text: pasteStatus, width }) : null, queuedInput !== undefined ? e(QueuedInputLine, { text: queuedInput, width }) : null, e(PromptLine, { text: promptDisplayText, cursor: promptDisplayCursor, busy, locked: inputLockedByQueue, placeholder: input.length === 0 && promptPlaceholder !== undefined, ghostText: activePlaceholder, width, prompt, slashCompletions, selectedSlashCompletionIndex, attachments }));
1106
1365
  }
1107
1366
  const MessageList = React.memo(function MessageList({ lines, width, liveMaxLines, lineIndexOffset = 0, onMarkdownRenderComplete }) {
1108
1367
  const contentWidth = messageContentWidth(width);
@@ -1128,9 +1387,17 @@ function MessageLine({ line, width, contentWidth = messageContentWidth(width), t
1128
1387
  const display = displayWindowForLine(line, summaryWidth, line.live ? liveMaxLines : undefined);
1129
1388
  return e(Box, { flexDirection: "row" }, useRoleMarker ? e(Text, { color: markerColorForKind(line.kind) }, messageRoleMarker(line.kind)) : null, e(Box, { flexDirection: "column", width: summaryWidth }, ...renderDisplayText(line, summaryWidth, display.maxLines, display.skipTop)));
1130
1389
  }
1131
- const clipPendingMarkdown = !line.live && onMarkdownRenderComplete !== undefined && lineNeedsDynamicRender(line, contentWidth);
1132
- const display = displayWindowForLine(line, contentWidth, line.live || clipPendingMarkdown ? liveMaxLines : undefined);
1133
- return e(Box, { flexDirection: "row" }, e(Text, { color: markerColorForKind(line.kind) }, messageRoleMarker(line.kind)), e(Box, { flexDirection: "column", width: contentWidth }, ...renderDisplayText(line, contentWidth, display.maxLines, display.skipTop, onMarkdownRenderComplete)));
1390
+ const useRoleMarker = !titleProvidesToolMarker(line);
1391
+ const lineWidth = useRoleMarker ? contentWidth : toolWidth;
1392
+ const clipPendingMarkdown = !line.live && onMarkdownRenderComplete !== undefined && lineNeedsDynamicRender(line, lineWidth);
1393
+ const display = displayWindowForLine(line, lineWidth, line.live || clipPendingMarkdown ? liveMaxLines : undefined);
1394
+ const contentNodes = [];
1395
+ if (line.title)
1396
+ contentNodes.push(renderBlockTitle(line));
1397
+ if (line.bodyTitle)
1398
+ contentNodes.push(e(Text, { key: `body-title-${line.id}`, bold: true }, line.bodyTitle));
1399
+ contentNodes.push(...renderDisplayText(line, lineWidth, display.maxLines, display.skipTop, onMarkdownRenderComplete));
1400
+ return e(Box, { flexDirection: "row" }, useRoleMarker ? e(Text, { color: markerColorForKind(line.kind) }, messageRoleMarker(line.kind)) : null, e(Box, { flexDirection: "column", width: lineWidth }, ...contentNodes));
1134
1401
  }
1135
1402
  function displayWindowForLine(line, width, maxLines) {
1136
1403
  if (maxLines === undefined)
@@ -1200,12 +1467,21 @@ function summaryTitle(line) {
1200
1467
  function summaryUsesRoleMarker(line) {
1201
1468
  return line.previewStyle === "summary" && (line.kind === "system" || line.kind === "meta");
1202
1469
  }
1470
+ function titleProvidesToolMarker(line) {
1471
+ return line.kind === "tool" && !!line.title && (line.title.startsWith("◇ ") || line.title.startsWith("◆ "));
1472
+ }
1203
1473
  function titleStatusMarker(status) {
1204
1474
  return status === "success" ? "✓" : "✗";
1205
1475
  }
1206
1476
  function titleStatusColor(status) {
1207
1477
  return status === "success" ? "green" : "red";
1208
1478
  }
1479
+ function renderBlockTitle(line) {
1480
+ const title = line.title ?? titleForKind(line.kind);
1481
+ if (!line.titleStatus)
1482
+ return e(Text, { key: `title-${line.id}`, color: colorForKind(line.kind), bold: true }, title);
1483
+ return e(Text, { key: `title-${line.id}`, color: colorForKind(line.kind), bold: true }, `${title} `, e(Text, { color: titleStatusColor(line.titleStatus), bold: true }, titleStatusMarker(line.titleStatus)));
1484
+ }
1209
1485
  function renderSummaryBlock(line, width, maxLines, skipTop = 0) {
1210
1486
  const allPreviewLines = renderSummaryLines(line, width);
1211
1487
  const preview = clipStrings(allPreviewLines, maxLines, skipTop);
@@ -1437,10 +1713,27 @@ function StatusBar({ status, animationTick, width: terminalWidth }) {
1437
1713
  const segments = fitStatusSegments(renderCompactStatusSegments(status, animationTick, width, inputTokens, outputTokens, displayPhase), width);
1438
1714
  return e(Box, { marginTop: 1, width, height: 1, overflow: "hidden" }, ...segments.map((segment, index) => e(Text, { key: index, color: segment.color ?? "gray", bold: segment.bold ?? false }, segment.text)));
1439
1715
  }
1440
- function BackgroundTaskStatusLine({ count, width: terminalWidth }) {
1716
+ function backgroundTaskStatusRenderRows(taskCount) {
1717
+ if (taskCount <= 0)
1718
+ return 0;
1719
+ return 1 + Math.min(taskCount, 2);
1720
+ }
1721
+ function BackgroundTaskStatusLine({ tasks, width: terminalWidth }) {
1441
1722
  const width = statusBarWidth(terminalWidth);
1442
- const text = count <= 3 ? "".repeat(Math.max(0, count)) : `◇×${count}`;
1443
- return e(Box, { width, height: 1, overflow: "hidden" }, e(Text, { color: "yellow" }, fitToWidth(text, width)));
1723
+ const summary = `◇ background tools: ${tasks.length} task${tasks.length === 1 ? "" : "s"}`;
1724
+ const detailTasks = tasks.slice(0, 2);
1725
+ return e(Box, { flexDirection: "column", width, overflow: "hidden" }, e(Text, { color: "yellow" }, fitToWidth(summary, width)), ...detailTasks.map((task) => e(Text, { key: task.taskId, color: "yellow" }, fitToWidth(` ${task.type}:${truncateMiddle(task.description || task.agentId || task.taskId, Math.max(12, width - 30))} · ${task.status} · ${formatElapsed(Date.now() - new Date(task.createdAt).getTime())}`, width))));
1726
+ }
1727
+ function formatElapsed(ms) {
1728
+ const seconds = Math.max(0, Math.floor(ms / 1000));
1729
+ if (seconds < 60)
1730
+ return `${seconds}s`;
1731
+ const minutes = Math.floor(seconds / 60);
1732
+ const remainder = seconds % 60;
1733
+ if (minutes < 60)
1734
+ return `${minutes}m${remainder.toString().padStart(2, "0")}s`;
1735
+ const hours = Math.floor(minutes / 60);
1736
+ return `${hours}h${(minutes % 60).toString().padStart(2, "0")}m`;
1444
1737
  }
1445
1738
  function renderCompactStatusSegments(status, animationTick, width, inputTokens, outputTokens, displayPhase = status.phase) {
1446
1739
  const phase = displayPhase;
@@ -1451,7 +1744,7 @@ function renderCompactStatusSegments(status, animationTick, width, inputTokens,
1451
1744
  const context = renderContextParts(status.metrics);
1452
1745
  const fixedText = [
1453
1746
  phaseText,
1454
- `ctx ${context.used} / ${context.limit} (${context.percent})`,
1747
+ context.percent,
1455
1748
  `↑ ${inputValue}`,
1456
1749
  `↓ ${outputValue}`,
1457
1750
  ].join(STATUS_SEPARATOR);
@@ -1468,9 +1761,7 @@ function renderCompactStatusSegments(status, animationTick, width, inputTokens,
1468
1761
  statusDividerSegment(),
1469
1762
  { text: model },
1470
1763
  statusDividerSegment(),
1471
- statusLabelSegment("ctx"),
1472
- { text: ` ${context.used} / ${context.limit}` },
1473
- { text: ` (${context.percent})`, color: contextColor(status.metrics) },
1764
+ { text: context.percent, color: contextColor(status.metrics) },
1474
1765
  statusDividerSegment(),
1475
1766
  statusLabelSegment("↑", tokenInputColor),
1476
1767
  { text: ` ${inputValue}` },
@@ -1616,10 +1907,16 @@ function selectedSlashCommandCompletion(text, cursor, selectedIndex) {
1616
1907
  return undefined;
1617
1908
  return completions[Math.max(0, Math.min(selectedIndex, completions.length - 1))];
1618
1909
  }
1619
- function PromptLine({ text, cursor, busy, locked, placeholder = false, width, prompt, slashCompletions, selectedSlashCompletionIndex, attachments }) {
1620
- const visualLines = promptTextView(text, cursor, width, prompt);
1910
+ function PromptLine({ text, cursor, busy, locked, placeholder = false, ghostText, width, prompt, slashCompletions, selectedSlashCompletionIndex, attachments }) {
1911
+ const displayText = text.length === 0 && ghostText ? ` ${ghostText}` : text;
1912
+ const displayCursor = text.length === 0 && ghostText ? 0 : cursor;
1913
+ const visualLines = promptTextView(displayText, displayCursor, width, prompt);
1621
1914
  const inputColor = placeholder ? "gray" : (!locked && isValidReplCommandLine(text) ? "cyan" : undefined);
1622
- return e(Box, { flexDirection: "column" }, ...visualLines.map((line, index) => e(Box, { key: `prompt-${index}`, height: 1, overflow: "hidden" }, e(Text, { color: locked ? "gray" : "cyan" }, index === 0 ? prompt : " ".repeat(prompt.length)), ...renderPromptPart(line.before, inputColor, attachments, `prompt-${index}-before`), e(Text, { key: `prompt-${index}-cursor`, inverse: true, color: inputColor }, line.selected), ...renderPromptPart(line.after, inputColor, attachments, `prompt-${index}-after`))), ...SlashCompletionLines({ completions: slashCompletions, width, prompt, selectedIndex: selectedSlashCompletionIndex }));
1915
+ return e(Box, { flexDirection: "column" }, ...visualLines.map((line, index) => {
1916
+ const isGhostLine = text.length === 0 && ghostText !== undefined;
1917
+ const afterColor = isGhostLine ? "gray" : inputColor;
1918
+ return e(Box, { key: `prompt-${index}`, height: 1, overflow: "hidden" }, e(Text, { color: locked ? "gray" : "cyan" }, index === 0 ? prompt : " ".repeat(prompt.length)), ...renderPromptPart(line.before, inputColor, attachments, `prompt-${index}-before`), e(Text, { key: `prompt-${index}-cursor`, inverse: true, color: inputColor }, line.selected), ...renderPromptPart(line.after, afterColor, attachments, `prompt-${index}-after`));
1919
+ }), ...SlashCompletionLines({ completions: slashCompletions, width, prompt, selectedIndex: selectedSlashCompletionIndex }));
1623
1920
  }
1624
1921
  function PasteStatusLine({ text, width: terminalWidth }) {
1625
1922
  const width = statusBarWidth(terminalWidth);
@@ -1627,7 +1924,7 @@ function PasteStatusLine({ text, width: terminalWidth }) {
1627
1924
  }
1628
1925
  function QueuedInputLine({ text, width: terminalWidth }) {
1629
1926
  const width = statusBarWidth(terminalWidth);
1630
- const preview = fitToWidth(`queued next: ${text.replace(/\s+/g, " ").trim()} (Esc to edit)`, width);
1927
+ const preview = fitToWidth(`pending next: ${text.replace(/\s+/g, " ").trim()} (Esc to edit)`, width);
1631
1928
  return e(Box, { width, height: 1, overflow: "hidden" }, e(Text, { color: "yellow" }, preview));
1632
1929
  }
1633
1930
  function renderPromptPart(text, color, attachments, keyPrefix) {
@@ -1678,17 +1975,41 @@ function SlashCompletionLines({ completions, width, prompt, selectedIndex }) {
1678
1975
  e(Text, { key: "slash-completion-footer", color: "gray" }, fitToWidth(footer, contentWidth)),
1679
1976
  ].map((line, index) => e(Box, { key: `slash-completion-line-${index}`, height: 1, overflow: "hidden" }, e(Text, { color: "gray" }, " ".repeat(prompt.length)), line));
1680
1977
  }
1681
- function handleModelCommand(command, runtime) {
1978
+ async function handleModelCommand(command, runtime) {
1682
1979
  const current = runtime.engine.getModelSettings();
1683
1980
  const nextModel = command.model ?? current.model;
1684
1981
  const validationError = validateModelReasoningArgument(nextModel, command.reasoning);
1685
1982
  if (validationError)
1686
1983
  return { kind: "error", text: validationError };
1687
1984
  const reasoningUpdate = resolveModelReasoningUpdate(command.reasoning, current.reasoning, nextModel, command.model !== undefined);
1688
- if (command.model !== undefined || command.reasoning !== undefined) {
1985
+ const changed = command.model !== undefined || command.reasoning !== undefined;
1986
+ if (changed) {
1689
1987
  runtime.engine.setModel(nextModel, reasoningUpdate.reasoning, reasoningUpdate.update);
1988
+ try {
1989
+ const { providerChanged } = await persistModelCommandSettings(runtime, command, reasoningUpdate);
1990
+ if (providerChanged) {
1991
+ const config = readModelProviderConfig(process.env);
1992
+ if (config) {
1993
+ const innerGateway = createModelGatewayFromConfig(config);
1994
+ runtime.modelGateway.setInner(innerGateway);
1995
+ runtime.agentRuntime.modelGateway = runtime.modelGateway;
1996
+ runtime.engine.setModelProvider({
1997
+ modelGateway: runtime.modelGateway,
1998
+ model: config.model,
1999
+ fallbackModel: config.fallbackModel,
2000
+ reasoning: config.defaultReasoning,
2001
+ });
2002
+ syncImageGenerationTool(runtime, config.provider);
2003
+ runtime.defaultReasoning = config.defaultReasoning;
2004
+ }
2005
+ }
2006
+ }
2007
+ catch (error) {
2008
+ return { kind: "error", text: `Model settings changed for this session, but saving to ${runtime.envPath} failed: ${error instanceof Error ? error.message : String(error)}` };
2009
+ }
1690
2010
  }
1691
- return systemLine(formatModelSettings(runtime.engine.getModelSettings(), runtime.defaultReasoning));
2011
+ const settings = formatModelSettings(runtime.engine.getModelSettings(), runtime.defaultReasoning);
2012
+ return systemLine(changed ? `${settings}\nSaved to ${runtime.envPath}` : settings);
1692
2013
  }
1693
2014
  function resolveModelReasoningUpdate(value, current, modelId, modelChanged) {
1694
2015
  if (value === "off")
@@ -1702,6 +2023,62 @@ function resolveModelReasoningUpdate(value, current, modelId, modelChanged) {
1702
2023
  }
1703
2024
  return { reasoning: current, update: false };
1704
2025
  }
2026
+ async function persistModelCommandSettings(runtime, command, reasoningUpdate) {
2027
+ const currentProvider = currentModelProvider();
2028
+ let targetProvider = currentProvider;
2029
+ const updates = {};
2030
+ if (command.model !== undefined) {
2031
+ const metadata = findModelMetadata(command.model);
2032
+ if (metadata) {
2033
+ const modelProvider = parseLoginProvider(metadata.provider);
2034
+ if (modelProvider) {
2035
+ targetProvider = modelProvider;
2036
+ if (targetProvider !== currentProvider)
2037
+ updates.MODEL_PROVIDER = targetProvider;
2038
+ }
2039
+ }
2040
+ updates[modelEnvKeyForProvider(targetProvider)] = command.model.trim() || undefined;
2041
+ }
2042
+ if (command.reasoning !== undefined || reasoningUpdate.update) {
2043
+ updates.MODEL_REASONING_EFFORT = envValueForReasoning(reasoningUpdate.reasoning);
2044
+ updates.MODEL_REASONING_SUMMARY = undefined;
2045
+ }
2046
+ if (Object.keys(updates).length === 0)
2047
+ return { providerChanged: false };
2048
+ await writeEnvUpdates(runtime.envPath, updates);
2049
+ applyEnvUpdatesToProcess(updates);
2050
+ runtime.defaultReasoning = reasoningUpdate.update ? reasoningUpdate.reasoning : runtime.defaultReasoning;
2051
+ return { providerChanged: targetProvider !== currentProvider };
2052
+ }
2053
+ function currentModelProvider() {
2054
+ return parseLoginProvider(process.env.MODEL_PROVIDER) ?? "openai";
2055
+ }
2056
+ function modelEnvKeyForProvider(provider) {
2057
+ if (provider === "deepseek")
2058
+ return "DEEPSEEK_MODEL";
2059
+ if (provider === "kimi")
2060
+ return "KIMI_MODEL";
2061
+ return "OPENAI_MODEL";
2062
+ }
2063
+ function envValueForReasoning(reasoning) {
2064
+ if (reasoning === null)
2065
+ return "off";
2066
+ return reasoning?.effort;
2067
+ }
2068
+ async function writeEnvUpdates(envPath, updates, removeKeys = []) {
2069
+ await fs.mkdir(path.dirname(envPath), { recursive: true });
2070
+ const existing = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
2071
+ const next = updateEnvContent(existing, updates, removeKeys);
2072
+ await fs.writeFile(envPath, next, "utf8");
2073
+ }
2074
+ function applyEnvUpdatesToProcess(updates) {
2075
+ for (const [key, value] of Object.entries(updates)) {
2076
+ if (value === undefined)
2077
+ delete process.env[key];
2078
+ else
2079
+ process.env[key] = value;
2080
+ }
2081
+ }
1705
2082
  function validateModelReasoningArgument(modelId, reasoning) {
1706
2083
  if (!reasoning || reasoning === "default" || reasoning === "off")
1707
2084
  return undefined;
@@ -1924,18 +2301,38 @@ function reduceStatus(status, event) {
1924
2301
  }
1925
2302
  return status;
1926
2303
  }
1927
- async function handleSessionsCommand(runtime, setBrowser, append) {
2304
+ async function handleSessionsCommand(runtime, runningSessionIds, setBrowser, append) {
1928
2305
  const sessions = await runtime.engine.listSessions(Number.POSITIVE_INFINITY);
1929
2306
  if (sessions.length === 0) {
1930
2307
  setBrowser(undefined);
1931
2308
  append(systemLine("No saved sessions found."));
1932
2309
  return;
1933
2310
  }
1934
- setBrowser({ sessions, pageSize: SESSIONS_DEFAULT_PAGE_SIZE, pageIndex: 0, selectedIndex: 0 });
2311
+ setBrowser({ sessions, runningSessionIds, pageSize: SESSIONS_DEFAULT_PAGE_SIZE, pageIndex: 0, selectedIndex: 0 });
2312
+ }
2313
+ async function handleExportCommand(command, runtime) {
2314
+ const snapshot = runtime.engine.snapshot();
2315
+ if (!snapshot.session)
2316
+ throw new Error("session transcripts are disabled; cannot export current session");
2317
+ const promptSnapshot = await runtime.engine.promptExportSnapshot();
2318
+ const result = await writeSessionMarkdownExport({
2319
+ outputPath: command.path,
2320
+ session: snapshot.session,
2321
+ agentId: snapshot.agentId,
2322
+ promptSnapshot,
2323
+ engineSnapshot: { ...snapshot, communicationLog: runtime.communicationLogger.snapshot(), usage: runtime.usage.snapshot() },
2324
+ });
2325
+ return systemLine(`Exported current session to ${result.outputPath}\nEntries: ${result.entries}\nMessages: ${result.messages}\nBytes: ${result.bytes}`);
1935
2326
  }
1936
2327
  async function handleResumeCommand(sessionId, runtime, append) {
1937
2328
  try {
1938
- return await runtime.engine.resumeSession(sessionId);
2329
+ runtime.engine = runtime.engine.forkForSession(sessionId, true);
2330
+ await runtime.engine.initialize();
2331
+ const snapshot = runtime.engine.snapshot().session;
2332
+ if (!snapshot)
2333
+ throw new Error("session transcripts are disabled");
2334
+ const metrics = await runtime.engine.contextMetrics();
2335
+ return { snapshot, metrics };
1939
2336
  }
1940
2337
  catch (error) {
1941
2338
  append({ kind: "error", text: error instanceof Error ? error.message : String(error) });
@@ -1960,6 +2357,7 @@ async function handleDeleteSessionCommand(sessionId, current, runtime, setBrowse
1960
2357
  setBrowser({
1961
2358
  ...current,
1962
2359
  sessions: nextSessions,
2360
+ runningSessionIds: current.runningSessionIds.filter((id) => id !== sessionId),
1963
2361
  pageIndex,
1964
2362
  selectedIndex: Math.min(current.selectedIndex, Math.max(0, pageLength - 1)),
1965
2363
  });
@@ -1976,11 +2374,11 @@ function initialLines(runtime, lineId) {
1976
2374
  ? ` Session: ${session.sessionId}${session.resumedMessages > 0 ? ` (${session.resumedMessages} resumed messages)` : ""}.`
1977
2375
  : "";
1978
2376
  const lines = [
1979
- { id: 0, kind: "system", title: "System", text: `Interactive UI enabled. Type /help for commands.${suffix}`, previewStyle: "summary" },
2377
+ { id: 0, kind: "system", title: "System", text: `Interactive UI enabled. Type /help for commands.${suffix}\n${formatTipLine(tipAt(initialTipIndex(session?.sessionId ?? process.cwd())))}`, previewStyle: "summary" },
1980
2378
  ];
1981
2379
  lineId.current = 0;
1982
2380
  if (runtime.envNotice)
1983
- lines.push({ id: ++lineId.current, kind: "system", title: "Config", text: runtime.envNotice, previewStyle: "summary" });
2381
+ lines.push({ id: ++lineId.current, kind: "system", title: "Config", text: runtime.envNotice, format: "plain", previewStyle: "summary" });
1984
2382
  for (const line of restoredHistoryLines(runtime))
1985
2383
  lines.push({ id: ++lineId.current, ...line });
1986
2384
  return lines;
@@ -1999,6 +2397,71 @@ function restoredHistoryLines(runtime) {
1999
2397
  }
2000
2398
  return lines;
2001
2399
  }
2400
+ const LOGIN_PROVIDERS = ["openai", "deepseek", "kimi"];
2401
+ const SHARED_LOGIN_FIELDS = [
2402
+ { key: "reasoningEffort", label: "Reasoning effort", envKey: "MODEL_REASONING_EFFORT", scope: "shared", options: ["", "off", "none", "minimal", "low", "medium", "high", "xhigh", "max"] },
2403
+ { key: "reasoningSummary", label: "Reasoning summary", envKey: "MODEL_REASONING_SUMMARY", scope: "shared", options: ["", "auto", "concise", "detailed"] },
2404
+ { key: "maxOutputTokens", label: "Max output tokens", envKey: "MODEL_MAX_OUTPUT_TOKENS", scope: "shared", placeholder: "800" },
2405
+ { key: "timeoutMs", label: "Timeout ms", envKey: "MODEL_TIMEOUT_MS", scope: "shared", placeholder: "120000" },
2406
+ { key: "streamIdleTimeoutMs", label: "Stream idle timeout ms", envKey: "MODEL_STREAM_IDLE_TIMEOUT_MS", scope: "shared", placeholder: "120000" },
2407
+ { key: "maxRetries", label: "Max retries", envKey: "MODEL_MAX_RETRIES", scope: "shared", placeholder: "2" },
2408
+ ];
2409
+ const LOGIN_FIELD_DEFINITIONS = {
2410
+ openai: [
2411
+ { key: "apiKey", label: "API key", envKey: "OPENAI_API_KEY", scope: "provider", required: true, secret: true, placeholder: "sk-..." },
2412
+ { key: "baseUrl", label: "Base URL", envKey: "OPENAI_BASE_URL", scope: "provider", placeholder: "https://api.openai.com" },
2413
+ { key: "model", label: "Model", envKey: "OPENAI_MODEL", scope: "provider", required: true, placeholder: "gpt-5.5" },
2414
+ { key: "fallbackModel", label: "Fallback model", envKey: "OPENAI_FALLBACK_MODEL", scope: "provider" },
2415
+ { key: "endpoint", label: "Endpoint", envKey: "OPENAI_ENDPOINT", scope: "provider", placeholder: "auto", options: ["auto", "responses", "chat"] },
2416
+ ...SHARED_LOGIN_FIELDS,
2417
+ ],
2418
+ deepseek: [
2419
+ { key: "apiKey", label: "API key", envKey: "DEEPSEEK_API_KEY", scope: "provider", required: true, secret: true, placeholder: "sk-..." },
2420
+ { key: "baseUrl", label: "Base URL", envKey: "DEEPSEEK_BASE_URL", scope: "provider", placeholder: "https://api.deepseek.com" },
2421
+ { key: "model", label: "Model", envKey: "DEEPSEEK_MODEL", scope: "provider", required: true, placeholder: "deepseek-chat" },
2422
+ { key: "fallbackModel", label: "Fallback model", envKey: "DEEPSEEK_FALLBACK_MODEL", scope: "provider" },
2423
+ ...SHARED_LOGIN_FIELDS,
2424
+ ],
2425
+ kimi: [
2426
+ { key: "apiKey", label: "API key", envKey: "KIMI_API_KEY", scope: "provider", required: true, secret: true, placeholder: "sk-..." },
2427
+ { key: "baseUrl", label: "Base URL", envKey: "KIMI_BASE_URL", scope: "provider", placeholder: "https://api.moonshot.cn/v1" },
2428
+ { key: "model", label: "Model", envKey: "KIMI_MODEL", scope: "provider", required: true, placeholder: "kimi-k2.6" },
2429
+ { key: "fallbackModel", label: "Fallback model", envKey: "KIMI_FALLBACK_MODEL", scope: "provider" },
2430
+ ...SHARED_LOGIN_FIELDS,
2431
+ ],
2432
+ };
2433
+ const DEPRECATED_MODEL_ENV_KEYS = [
2434
+ "MODEL_API_KEY",
2435
+ "MODEL_BASE_URL",
2436
+ "MODEL_ID",
2437
+ "MODEL_FALLBACK_ID",
2438
+ "MODEL_ENDPOINT",
2439
+ "OPENAI_PROVIDER",
2440
+ "OPENAI_REASONING_EFFORT",
2441
+ "OPENAI_REASONING_SUMMARY",
2442
+ "OPENAI_MAX_OUTPUT_TOKENS",
2443
+ "OPENAI_TIMEOUT_MS",
2444
+ "OPENAI_STREAM_IDLE_TIMEOUT_MS",
2445
+ "OPENAI_MAX_RETRIES",
2446
+ "DEEPSEEK_REASONING_EFFORT",
2447
+ "DEEPSEEK_REASONING_SUMMARY",
2448
+ "DEEPSEEK_MAX_OUTPUT_TOKENS",
2449
+ "DEEPSEEK_TIMEOUT_MS",
2450
+ "DEEPSEEK_STREAM_IDLE_TIMEOUT_MS",
2451
+ "DEEPSEEK_MAX_RETRIES",
2452
+ "KIMI_REASONING_EFFORT",
2453
+ "KIMI_REASONING_SUMMARY",
2454
+ "KIMI_MAX_OUTPUT_TOKENS",
2455
+ "KIMI_TIMEOUT_MS",
2456
+ "KIMI_STREAM_IDLE_TIMEOUT_MS",
2457
+ "KIMI_MAX_RETRIES",
2458
+ "MOONSHOT_REASONING_EFFORT",
2459
+ "MOONSHOT_REASONING_SUMMARY",
2460
+ "MOONSHOT_MAX_OUTPUT_TOKENS",
2461
+ "MOONSHOT_TIMEOUT_MS",
2462
+ "MOONSHOT_STREAM_IDLE_TIMEOUT_MS",
2463
+ "MOONSHOT_MAX_RETRIES",
2464
+ ];
2002
2465
  function sessionsPageCount(state) {
2003
2466
  return Math.max(1, Math.ceil(state.sessions.length / state.pageSize));
2004
2467
  }
@@ -2041,23 +2504,363 @@ function SessionsBrowser({ state, width }) {
2041
2504
  return e(Box, { flexDirection: "column", marginTop: 1 }, e(Text, { color: "cyan", bold: true }, fitToWidth(header, contentWidth)), ...pageItems.map((session, index) => {
2042
2505
  const selected = index === state.selectedIndex;
2043
2506
  const absoluteIndex = state.pageIndex * state.pageSize + index;
2044
- const row = formatSessionBrowserRow(session, absoluteIndex, contentWidth);
2507
+ const row = formatSessionBrowserRow(session, absoluteIndex, contentWidth, state.runningSessionIds.includes(session.sessionId));
2045
2508
  return e(Text, { key: session.sessionId, color: "white" }, e(Text, {
2046
2509
  color: selected ? "black" : "white",
2047
2510
  backgroundColor: selected ? "cyan" : undefined,
2048
2511
  }, row.numberPrefix), row.rest);
2049
2512
  }), e(Text, { color: "gray" }, fitToWidth(footer, contentWidth)));
2050
2513
  }
2051
- function formatSessionBrowserRow(session, absoluteIndex, width) {
2514
+ function handleLoginFormInput(value, key, state, setLoginFormState, runtime, append, setStatus) {
2515
+ if (key.escape) {
2516
+ if (state.step === "fields")
2517
+ setLoginFormState({ ...state, step: "provider" });
2518
+ else {
2519
+ setLoginFormState(undefined);
2520
+ append(systemLine("Login cancelled."));
2521
+ }
2522
+ return;
2523
+ }
2524
+ if (state.step === "provider") {
2525
+ if (key.upArrow) {
2526
+ setLoginFormState(moveLoginProviderSelection(state, -1));
2527
+ return;
2528
+ }
2529
+ if (key.downArrow) {
2530
+ setLoginFormState(moveLoginProviderSelection(state, 1));
2531
+ return;
2532
+ }
2533
+ if (key.return) {
2534
+ const provider = state.providers[state.selectedProviderIndex] ?? state.provider;
2535
+ setLoginFormState({ ...loginFormForProvider(provider, state.envPath), step: "fields" });
2536
+ return;
2537
+ }
2538
+ return;
2539
+ }
2540
+ const fields = LOGIN_FIELD_DEFINITIONS[state.provider];
2541
+ const field = fields[state.selectedFieldIndex];
2542
+ if (!field)
2543
+ return;
2544
+ if (key.upArrow) {
2545
+ setLoginFormState(moveLoginFieldSelection(state, -1));
2546
+ return;
2547
+ }
2548
+ if (key.downArrow) {
2549
+ setLoginFormState(moveLoginFieldSelection(state, 1));
2550
+ return;
2551
+ }
2552
+ if (key.leftArrow) {
2553
+ setLoginFormState({ ...state, cursor: Math.max(0, state.cursor - 1) });
2554
+ return;
2555
+ }
2556
+ if (key.rightArrow) {
2557
+ const current = state.values[field.key] ?? "";
2558
+ setLoginFormState({ ...state, cursor: Math.min(current.length, state.cursor + 1) });
2559
+ return;
2560
+ }
2561
+ if (key.tab && field.options?.length) {
2562
+ setLoginFormState(cycleLoginFieldOption(state, field));
2563
+ return;
2564
+ }
2565
+ if (key.backspace || key.delete) {
2566
+ setLoginFormState(deleteLoginFieldCharacter(state, field));
2567
+ return;
2568
+ }
2569
+ if (key.return) {
2570
+ void submitLoginForm(state, runtime, append, setLoginFormState, setStatus);
2571
+ return;
2572
+ }
2573
+ if (value && !key.ctrl && !key.meta) {
2574
+ setLoginFormState(insertLoginFieldText(state, field, value));
2575
+ }
2576
+ }
2577
+ function moveLoginProviderSelection(state, delta) {
2578
+ const selectedProviderIndex = (state.selectedProviderIndex + delta + state.providers.length) % state.providers.length;
2579
+ return { ...state, selectedProviderIndex, provider: state.providers[selectedProviderIndex] ?? state.provider };
2580
+ }
2581
+ function moveLoginFieldSelection(state, delta) {
2582
+ const fields = LOGIN_FIELD_DEFINITIONS[state.provider];
2583
+ const selectedFieldIndex = (state.selectedFieldIndex + delta + fields.length) % fields.length;
2584
+ const field = fields[selectedFieldIndex];
2585
+ return { ...state, selectedFieldIndex, cursor: field ? (state.values[field.key] ?? "").length : 0 };
2586
+ }
2587
+ function cycleLoginFieldOption(state, field) {
2588
+ const options = field.options ?? [];
2589
+ const current = state.values[field.key] ?? "";
2590
+ const index = options.indexOf(current);
2591
+ const next = options[(index + 1 + options.length) % options.length] ?? "";
2592
+ return { ...state, values: { ...state.values, [field.key]: next }, cursor: next.length };
2593
+ }
2594
+ function insertLoginFieldText(state, field, value) {
2595
+ const current = state.values[field.key] ?? "";
2596
+ const cursor = Math.max(0, Math.min(state.cursor, current.length));
2597
+ const next = `${current.slice(0, cursor)}${value}${current.slice(cursor)}`;
2598
+ return { ...state, values: { ...state.values, [field.key]: next }, cursor: cursor + value.length };
2599
+ }
2600
+ function deleteLoginFieldCharacter(state, field) {
2601
+ const current = state.values[field.key] ?? "";
2602
+ const cursor = Math.max(0, Math.min(state.cursor, current.length));
2603
+ if (cursor <= 0)
2604
+ return state;
2605
+ const next = `${current.slice(0, cursor - 1)}${current.slice(cursor)}`;
2606
+ return { ...state, values: { ...state.values, [field.key]: next }, cursor: cursor - 1 };
2607
+ }
2608
+ async function submitLoginForm(state, runtime, append, setLoginFormState, setStatus) {
2609
+ const validationError = validateLoginForm(state);
2610
+ if (validationError) {
2611
+ append({ kind: "error", text: validationError });
2612
+ return;
2613
+ }
2614
+ try {
2615
+ await saveLoginFormToEnv(state);
2616
+ applyLoginFormToProcessEnv(state);
2617
+ const config = readModelProviderConfig(process.env);
2618
+ if (!config)
2619
+ throw new Error("Saved provider config could not be loaded from environment.");
2620
+ const innerGateway = createModelGatewayFromConfig(config);
2621
+ runtime.modelGateway.setInner(innerGateway);
2622
+ runtime.agentRuntime.modelGateway = runtime.modelGateway;
2623
+ runtime.engine.setModelProvider({
2624
+ modelGateway: runtime.modelGateway,
2625
+ model: config.model,
2626
+ fallbackModel: config.fallbackModel,
2627
+ reasoning: config.defaultReasoning,
2628
+ });
2629
+ syncImageGenerationTool(runtime, config.provider);
2630
+ runtime.defaultReasoning = config.defaultReasoning;
2631
+ const metrics = await runtime.engine.contextMetrics();
2632
+ setStatus((current) => ({
2633
+ ...current,
2634
+ metrics,
2635
+ activityTick: current.activityTick + 1,
2636
+ }));
2637
+ setLoginFormState(undefined);
2638
+ append(systemLine(`Saved ${state.provider} login to ${state.envPath}\n${formatModelSettings(runtime.engine.getModelSettings(), runtime.defaultReasoning)}`, EXPANDED_SUMMARY_MAX_LINES));
2639
+ }
2640
+ catch (error) {
2641
+ append({ kind: "error", text: `Login save failed: ${error instanceof Error ? error.message : String(error)}` });
2642
+ }
2643
+ }
2644
+ function validateLoginForm(state) {
2645
+ for (const field of LOGIN_FIELD_DEFINITIONS[state.provider]) {
2646
+ const value = (state.values[field.key] ?? "").trim();
2647
+ if (field.required && !value)
2648
+ return `${field.label} is required.`;
2649
+ if (field.options?.length && value && !field.options.includes(value))
2650
+ return `${field.label} must be one of: ${field.options.filter(Boolean).join(", ")}`;
2651
+ }
2652
+ for (const fieldKey of ["maxOutputTokens", "timeoutMs", "streamIdleTimeoutMs", "maxRetries"]) {
2653
+ const value = state.values[fieldKey]?.trim();
2654
+ if (value && !Number.isFinite(Number(value)))
2655
+ return `${fieldKey} must be a number.`;
2656
+ }
2657
+ return undefined;
2658
+ }
2659
+ function createLoginFormState(envPath = getUserDotEnvPath()) {
2660
+ const env = parseEnvFileSafe(envPath);
2661
+ const currentProvider = parseLoginProvider(env.MODEL_PROVIDER ?? process.env.MODEL_PROVIDER) ?? guessLoginProvider(env);
2662
+ return loginFormForProvider(currentProvider, envPath, env);
2663
+ }
2664
+ function loginFormForProvider(provider, envPath, env = parseEnvFileSafe(envPath)) {
2665
+ const selectedProviderIndex = Math.max(0, LOGIN_PROVIDERS.indexOf(provider));
2666
+ return {
2667
+ step: "provider",
2668
+ providers: LOGIN_PROVIDERS,
2669
+ selectedProviderIndex,
2670
+ provider,
2671
+ selectedFieldIndex: 0,
2672
+ cursor: 0,
2673
+ values: loginValuesForProvider(provider, env),
2674
+ envPath,
2675
+ };
2676
+ }
2677
+ function loginValuesForProvider(provider, env) {
2678
+ const values = {};
2679
+ for (const field of LOGIN_FIELD_DEFINITIONS[provider]) {
2680
+ values[field.key] = env[field.envKey] ?? "";
2681
+ }
2682
+ if (provider === "kimi") {
2683
+ values.apiKey ||= env.MOONSHOT_API_KEY ?? process.env.MOONSHOT_API_KEY ?? "";
2684
+ values.baseUrl ||= env.MOONSHOT_BASE_URL ?? process.env.MOONSHOT_BASE_URL ?? "";
2685
+ values.model ||= env.MOONSHOT_MODEL ?? process.env.MOONSHOT_MODEL ?? "";
2686
+ values.fallbackModel ||= env.MOONSHOT_FALLBACK_MODEL ?? process.env.MOONSHOT_FALLBACK_MODEL ?? "";
2687
+ }
2688
+ if (!values.baseUrl)
2689
+ values.baseUrl = defaultBaseUrlForLoginProvider(provider);
2690
+ if (!values.model)
2691
+ values.model = defaultModelForLoginProvider(provider);
2692
+ if (provider === "openai" && !values.endpoint)
2693
+ values.endpoint = "auto";
2694
+ return values;
2695
+ }
2696
+ function parseLoginProvider(value) {
2697
+ if (value === "openai" || value === "deepseek" || value === "kimi")
2698
+ return value;
2699
+ return undefined;
2700
+ }
2701
+ function guessLoginProvider(env) {
2702
+ if (env.KIMI_API_KEY ?? env.MOONSHOT_API_KEY ?? process.env.KIMI_API_KEY ?? process.env.MOONSHOT_API_KEY)
2703
+ return "kimi";
2704
+ if (env.DEEPSEEK_API_KEY ?? process.env.DEEPSEEK_API_KEY)
2705
+ return "deepseek";
2706
+ return "openai";
2707
+ }
2708
+ function defaultBaseUrlForLoginProvider(provider) {
2709
+ if (provider === "deepseek")
2710
+ return "https://api.deepseek.com";
2711
+ if (provider === "kimi")
2712
+ return "https://api.moonshot.cn/v1";
2713
+ return "https://api.openai.com";
2714
+ }
2715
+ function defaultModelForLoginProvider(provider) {
2716
+ if (provider === "deepseek")
2717
+ return "deepseek-chat";
2718
+ if (provider === "kimi")
2719
+ return "kimi-k2.6";
2720
+ return "gpt-5.5";
2721
+ }
2722
+ function loginFormViewHeight(state) {
2723
+ return state.step === "provider" ? state.providers.length + 3 : LOGIN_FIELD_DEFINITIONS[state.provider].length + 4;
2724
+ }
2725
+ function LoginFormView({ state, width }) {
2726
+ const contentWidth = Math.max(30, width);
2727
+ if (state.step === "provider") {
2728
+ return e(Box, { flexDirection: "column", marginTop: 1 }, e(Text, { color: "cyan", bold: true }, fitToWidth(`Login: choose provider · saving to ${state.envPath}`, contentWidth)), ...state.providers.map((provider, index) => e(Text, { key: provider, color: "white" }, e(Text, { color: index === state.selectedProviderIndex ? "black" : "white", backgroundColor: index === state.selectedProviderIndex ? "cyan" : undefined }, `${index + 1}.`.padStart(3)), e(Text, { color: "gray" }, " "), e(Text, { color: "cyan" }, provider))), e(Text, { color: "gray" }, fitToWidth("↑/↓ select · Enter edit config · Esc close", contentWidth)));
2729
+ }
2730
+ const fields = LOGIN_FIELD_DEFINITIONS[state.provider];
2731
+ const maxLabel = Math.max(...fields.map((field) => field.label.length));
2732
+ return e(Box, { flexDirection: "column", marginTop: 1 }, e(Text, { color: "cyan", bold: true }, fitToWidth(`Login: ${state.provider} · ${state.envPath}`, contentWidth)), ...fields.map((field, index) => {
2733
+ const selected = index === state.selectedFieldIndex;
2734
+ const rawValue = state.values[field.key] ?? "";
2735
+ const visibleValue = formatLoginFieldValue(field, rawValue, selected ? state.cursor : undefined);
2736
+ const placeholder = rawValue ? "" : (field.placeholder ? ` (${field.placeholder})` : "");
2737
+ return e(Text, { key: field.key, color: "white" }, e(Text, { color: selected ? "black" : "white", backgroundColor: selected ? "cyan" : undefined }, `${index + 1}.`.padStart(3)), e(Text, { color: field.required ? "yellow" : "gray" }, ` ${field.label.padEnd(maxLabel)} `), e(Text, { color: field.scope === "shared" ? "blue" : "gray" }, field.scope === "shared" ? "shared " : "provider "), e(Text, { color: rawValue ? "white" : "gray" }, fitToWidth(`${visibleValue}${placeholder}`, Math.max(8, contentWidth - maxLabel - 14))));
2738
+ }), e(Text, { color: "gray" }, fitToWidth("↑/↓ field · ←/→ cursor · type edit · Tab cycle choices · Enter save · Esc back/cancel", contentWidth)), e(Text, { color: "gray" }, fitToWidth("Provider fields save as OPENAI_* / DEEPSEEK_* / KIMI_*; shared runtime fields save as MODEL_*.", contentWidth)));
2739
+ }
2740
+ function formatLoginFieldValue(field, value, cursor) {
2741
+ const display = field.secret && value ? "•".repeat(Math.min(value.length, 24)) : value;
2742
+ if (cursor === undefined)
2743
+ return display;
2744
+ const safeCursor = Math.max(0, Math.min(cursor, display.length));
2745
+ const selected = display[safeCursor] ?? " ";
2746
+ return `${display.slice(0, safeCursor)}█${selected === " " ? "" : display.slice(safeCursor + 1)}`;
2747
+ }
2748
+ function applyLoginFormToProcessEnv(state) {
2749
+ applyEnvUpdatesToProcess(envEntriesForLoginForm(state));
2750
+ for (const key of DEPRECATED_MODEL_ENV_KEYS)
2751
+ delete process.env[key];
2752
+ }
2753
+ async function saveLoginFormToEnv(state) {
2754
+ await writeEnvUpdates(state.envPath, envEntriesForLoginForm(state), DEPRECATED_MODEL_ENV_KEYS);
2755
+ }
2756
+ function envEntriesForLoginForm(state) {
2757
+ const entries = {
2758
+ MODEL_PROVIDER: state.provider,
2759
+ };
2760
+ for (const field of LOGIN_FIELD_DEFINITIONS[state.provider]) {
2761
+ const value = (state.values[field.key] ?? "").trim();
2762
+ entries[field.envKey] = value || undefined;
2763
+ }
2764
+ if (state.provider === "kimi") {
2765
+ entries.MOONSHOT_API_KEY = undefined;
2766
+ entries.MOONSHOT_BASE_URL = undefined;
2767
+ entries.MOONSHOT_MODEL = undefined;
2768
+ entries.MOONSHOT_FALLBACK_MODEL = undefined;
2769
+ }
2770
+ return entries;
2771
+ }
2772
+ function updateEnvContent(content, updates, removeKeys = []) {
2773
+ const keys = new Set(Object.keys(updates));
2774
+ const removals = new Set(removeKeys);
2775
+ const seen = new Set();
2776
+ const lines = content ? content.split(/\r?\n/) : [];
2777
+ const updatedLines = lines.map((line) => {
2778
+ const parsed = parseEnvLine(line);
2779
+ if (!parsed)
2780
+ return line;
2781
+ if (removals.has(parsed.key) && !keys.has(parsed.key))
2782
+ return undefined;
2783
+ if (!keys.has(parsed.key))
2784
+ return line;
2785
+ seen.add(parsed.key);
2786
+ const value = updates[parsed.key];
2787
+ if (value === undefined)
2788
+ return undefined;
2789
+ return `${parsed.key}=${quoteEnvValue(value)}`;
2790
+ }).filter((line) => line !== undefined);
2791
+ const missing = Object.entries(updates).filter((entry) => !seen.has(entry[0]) && entry[1] !== undefined);
2792
+ if (missing.length > 0) {
2793
+ const grouped = groupLoginEnvEntries(missing);
2794
+ appendEnvGroup(updatedLines, "# Neo active provider", grouped.active);
2795
+ appendEnvGroup(updatedLines, "# OpenAI provider settings", grouped.openai);
2796
+ appendEnvGroup(updatedLines, "# DeepSeek provider settings", grouped.deepseek);
2797
+ appendEnvGroup(updatedLines, "# Kimi provider settings", grouped.kimi);
2798
+ appendEnvGroup(updatedLines, "# Shared model runtime settings", grouped.shared);
2799
+ }
2800
+ return `${updatedLines.join("\n").replace(/\n*$/u, "")}\n`;
2801
+ }
2802
+ function groupLoginEnvEntries(entries) {
2803
+ return {
2804
+ active: entries.filter(([key]) => key === "MODEL_PROVIDER"),
2805
+ openai: entries.filter(([key]) => key.startsWith("OPENAI_")),
2806
+ deepseek: entries.filter(([key]) => key.startsWith("DEEPSEEK_")),
2807
+ kimi: entries.filter(([key]) => key.startsWith("KIMI_") || key.startsWith("MOONSHOT_")),
2808
+ shared: entries.filter(([key]) => key.startsWith("MODEL_") && key !== "MODEL_PROVIDER"),
2809
+ };
2810
+ }
2811
+ function appendEnvGroup(lines, header, entries) {
2812
+ if (entries.length === 0)
2813
+ return;
2814
+ if (lines.length > 0 && lines[lines.length - 1]?.trim())
2815
+ lines.push("");
2816
+ lines.push(header);
2817
+ for (const [key, value] of entries)
2818
+ lines.push(`${key}=${quoteEnvValue(value)}`);
2819
+ }
2820
+ function parseEnvFileSafe(envPath) {
2821
+ if (!existsSync(envPath))
2822
+ return {};
2823
+ const env = {};
2824
+ for (const line of readFileSync(envPath, "utf8").split(/\r?\n/)) {
2825
+ const parsed = parseEnvLine(line);
2826
+ if (parsed)
2827
+ env[parsed.key] = stripEnvQuotes(parsed.value.trim());
2828
+ }
2829
+ return env;
2830
+ }
2831
+ function parseEnvLine(line) {
2832
+ const trimmed = line.trim();
2833
+ if (!trimmed || trimmed.startsWith("#"))
2834
+ return undefined;
2835
+ const separator = trimmed.indexOf("=");
2836
+ if (separator <= 0)
2837
+ return undefined;
2838
+ const key = trimmed.slice(0, separator).trim();
2839
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
2840
+ return undefined;
2841
+ return { key, value: trimmed.slice(separator + 1) };
2842
+ }
2843
+ function quoteEnvValue(value) {
2844
+ if (/^[A-Za-z0-9_./:@+-]*$/.test(value))
2845
+ return value;
2846
+ return JSON.stringify(value);
2847
+ }
2848
+ function stripEnvQuotes(value) {
2849
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))
2850
+ return value.slice(1, -1);
2851
+ return value;
2852
+ }
2853
+ function formatSessionBrowserRow(session, absoluteIndex, width, running = false) {
2052
2854
  const numberPrefix = `${absoluteIndex + 1}.`.padStart(4);
2053
2855
  const title = session.title?.trim() || "(untitled)";
2856
+ const runningTag = running ? " · running" : "";
2054
2857
  const updated = session.updatedAt ? ` · ${formatSessionTimestamp(session.updatedAt)}` : "";
2055
2858
  const messages = ` · ${session.messages} messages`;
2056
- const fixedParts = `${numberPrefix} ${updated}${messages}`;
2859
+ const fixedParts = `${numberPrefix} ${runningTag}${updated}${messages}`;
2057
2860
  const idBudget = Math.max(12, Math.min(32, Math.floor(width * 0.28)));
2058
2861
  const id = truncateMiddle(session.sessionId, idBudget);
2059
2862
  const titleBudget = Math.max(8, width - fixedParts.length - id.length - 5);
2060
- const row = fitToWidth(`${numberPrefix} ${truncateMiddle(title, titleBudget)} · ${id}${updated}${messages}`, width);
2863
+ const row = fitToWidth(`${numberPrefix} ${truncateMiddle(title, titleBudget)} · ${id}${runningTag}${updated}${messages}`, width);
2061
2864
  return { numberPrefix, rest: row.slice(numberPrefix.length) };
2062
2865
  }
2063
2866
  function formatSessionTimestamp(value) {
@@ -2136,7 +2939,7 @@ function kindForRole(role) {
2136
2939
  }
2137
2940
  function titleForKind(kind) {
2138
2941
  if (kind === "thinking")
2139
- return `${THINKING_MARKER} Think`;
2942
+ return `${THINKING_MARKER} think`;
2140
2943
  if (kind === "tool")
2141
2944
  return "Tool";
2142
2945
  if (kind === "error")
@@ -2190,6 +2993,7 @@ function formatToolUse(toolUse) {
2190
2993
  return {
2191
2994
  kind: "tool",
2192
2995
  title: toolTitle(toolUse.name, "running"),
2996
+ bodyTitle: planToolBodyTitle(toolUse.input),
2193
2997
  text: formatPlanToolPayload(toolUse.input),
2194
2998
  };
2195
2999
  }
@@ -2205,6 +3009,7 @@ function formatToolResultLine(toolName, output, ok) {
2205
3009
  const line = {
2206
3010
  kind: ok ? "tool" : "error",
2207
3011
  title: toolTitle(toolName, "finished"),
3012
+ bodyTitle: formatted.bodyTitle,
2208
3013
  titleStatus: ok ? "success" : "failure",
2209
3014
  text: formatted.text,
2210
3015
  format: formatted.format,
@@ -2246,10 +3051,12 @@ function isPlanToolPayload(value) {
2246
3051
  (item.status === "pending" || item.status === "in_progress" || item.status === "completed"));
2247
3052
  });
2248
3053
  }
3054
+ function planToolBodyTitle(payload) {
3055
+ const title = payload.title?.trim();
3056
+ return title ? title : undefined;
3057
+ }
2249
3058
  function formatPlanToolPayload(payload) {
2250
3059
  const sections = [];
2251
- if (payload.title?.trim())
2252
- sections.push(`**${payload.title.trim()}**`);
2253
3060
  if (payload.summary?.trim())
2254
3061
  sections.push(payload.summary.trim());
2255
3062
  if (payload.note?.trim())
@@ -2337,26 +3144,11 @@ function isReplScalar(value) {
2337
3144
  return value === null || value === undefined || typeof value !== "object" || value instanceof Date;
2338
3145
  }
2339
3146
  function formatToolResult(toolName, output, ok) {
2340
- if (toolName === "edit" && isRecord(output) && isEditToolOutput(output)) {
3147
+ if ((toolName === "edit" || toolName === "write") && isRecord(output) && isEditToolOutput(output)) {
2341
3148
  return { text: formatEditToolDiff(output, ok), format: "ansi", summaryMaxLines: EDIT_TOOL_SUMMARY_MAX_LINES };
2342
3149
  }
2343
3150
  if (isExecOutput(output)) {
2344
- const status = output.timedOut
2345
- ? "timed out"
2346
- : output.exitCode === 0
2347
- ? "exit 0"
2348
- : `exit ${output.exitCode ?? output.signal ?? "unknown"}`;
2349
- const sections = [
2350
- `${status} · ${output.durationMs}ms`,
2351
- `$ ${output.command}`,
2352
- ];
2353
- if (output.stdout)
2354
- sections.push("stdout:", output.stdout.replace(/\s+$/u, ""));
2355
- if (output.stderr)
2356
- sections.push("stderr:", output.stderr.replace(/\s+$/u, ""));
2357
- if (!output.stdout && !output.stderr)
2358
- sections.push(ok ? "no output" : "no captured output");
2359
- return { text: sections.join("\n"), format: "ansi" };
3151
+ return { text: formatExecToolResult(output, ok), format: "ansi", summaryMaxLines: EXPANDED_SUMMARY_MAX_LINES };
2360
3152
  }
2361
3153
  if (typeof output === "string" && hasAnsi(output)) {
2362
3154
  return { text: output, format: "ansi" };
@@ -2373,8 +3165,11 @@ function formatToolResult(toolName, output, ok) {
2373
3165
  if (toolName === "search" && isRecord(output)) {
2374
3166
  return { text: formatWebSearchToolResult(output, ok), summaryMaxLines: EXPANDED_SUMMARY_MAX_LINES };
2375
3167
  }
3168
+ if (toolName === "image2" && isRecord(output)) {
3169
+ return { text: formatImageGenerationToolResult(output, ok), summaryMaxLines: 4 };
3170
+ }
2376
3171
  if (toolName === "plan" && isPlanToolPayload(output)) {
2377
- return { text: formatPlanToolPayload(output), full: true };
3172
+ return { text: formatPlanToolPayload(output), bodyTitle: planToolBodyTitle(output), full: true };
2378
3173
  }
2379
3174
  return { text: `${ok ? "ok" : "failed"}\n${formatJson(output, 6000)}`, summaryMaxLines: EXPANDED_SUMMARY_MAX_LINES };
2380
3175
  }
@@ -2463,9 +3258,58 @@ function isExecOutput(value) {
2463
3258
  typeof record.stdout === "string" &&
2464
3259
  typeof record.stderr === "string");
2465
3260
  }
3261
+ function formatExecToolResult(output, ok) {
3262
+ const status = output.timedOut
3263
+ ? "timed out"
3264
+ : output.exitCode === 0
3265
+ ? "exit 0"
3266
+ : `exit ${output.exitCode ?? output.signal ?? "unknown"}`;
3267
+ const lines = [
3268
+ "exec result",
3269
+ `status: ${status}`,
3270
+ `duration: ${output.durationMs}ms`,
3271
+ `command: ${output.command}`,
3272
+ ];
3273
+ const stdout = output.stdout.replace(/\s+$/u, "");
3274
+ const stderr = output.stderr.replace(/\s+$/u, "");
3275
+ if (stdout)
3276
+ lines.push("stdout:", stdout);
3277
+ if (stderr)
3278
+ lines.push("stderr:", stderr);
3279
+ if (!stdout && !stderr)
3280
+ lines.push(ok ? "output: (none)" : "output: (not captured)");
3281
+ return lines.join("\n");
3282
+ }
2466
3283
  function isRecord(value) {
2467
3284
  return !!value && typeof value === "object" && !Array.isArray(value);
2468
3285
  }
3286
+ function formatImageGenerationToolResult(output, ok) {
3287
+ const error = typeof output.error === "string" ? output.error : undefined;
3288
+ const mode = output.mode === "edit" ? "edit" : "generate";
3289
+ if (!ok || error)
3290
+ return [`image ${mode} failed`, error ?? formatReplData(output, 1200)].join("\n");
3291
+ const provider = typeof output.provider === "string" ? output.provider : "openai";
3292
+ const model = typeof output.model === "string" ? output.model : undefined;
3293
+ const returnedImages = typeof output.returnedImages === "number" ? output.returnedImages : Array.isArray(output.images) ? output.images.length : undefined;
3294
+ const size = typeof output.size === "string" ? output.size : undefined;
3295
+ const quality = typeof output.quality === "string" ? output.quality : undefined;
3296
+ const format = typeof output.outputFormat === "string" ? output.outputFormat : undefined;
3297
+ const sourceImages = typeof output.sourceImages === "number" ? output.sourceImages : undefined;
3298
+ const lines = [`${mode === "edit" ? "edited" : "generated"} ${returnedImages ?? 0} image${returnedImages === 1 ? "" : "s"}`];
3299
+ const details = [provider, model, size, quality && quality !== "auto" ? quality : undefined, format].filter((value) => Boolean(value));
3300
+ if (details.length > 0)
3301
+ lines.push(details.join(" · "));
3302
+ if (sourceImages !== undefined)
3303
+ lines.push(`source images: ${sourceImages}`);
3304
+ const duration = imageGenerationDuration(output);
3305
+ if (duration !== undefined)
3306
+ lines.push(`duration: ${duration}ms`);
3307
+ return lines.join("\n");
3308
+ }
3309
+ function imageGenerationDuration(output) {
3310
+ const value = output.duration ?? output.elapsed ?? output.durationMs ?? output.elapsedMs;
3311
+ return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.round(value)) : undefined;
3312
+ }
2469
3313
  function formatListToolResult(output, ok) {
2470
3314
  const pathValue = typeof output.path === "string" ? output.path : "";
2471
3315
  const typeValue = typeof output.type === "string" ? output.type : "result";
@@ -2613,11 +3457,9 @@ function formatGrepContextLine(line, marker) {
2613
3457
  }
2614
3458
  function renderContextParts(metrics) {
2615
3459
  if (!metrics)
2616
- return { used: "?", limit: "?", percent: "?" };
2617
- const used = compactNumber(metrics.estimatedInputTokens);
2618
- const limit = metrics.contextWindowTokens ? compactNumber(metrics.contextWindowTokens) : "?";
3460
+ return { percent: "?" };
2619
3461
  const percent = metrics.contextUsageRatio === undefined ? "?" : `${(metrics.contextUsageRatio * 100).toFixed(1)}%`;
2620
- return { used, limit, percent };
3462
+ return { percent };
2621
3463
  }
2622
3464
  function contextColor(metrics) {
2623
3465
  const ratio = metrics?.contextUsageRatio;
@@ -2900,9 +3742,8 @@ function isFullWidthCodePoint(codePoint) {
2900
3742
  (codePoint >= 0x20000 && codePoint <= 0x3fffd)));
2901
3743
  }
2902
3744
  const SESSIONS_DEFAULT_PAGE_SIZE = 10;
2903
- const TERMINAL_TITLE_DOT_FILLED_PREFIX = "● ";
2904
- const TERMINAL_TITLE_DOT_BLANK_PREFIX = " ";
2905
- const TERMINAL_TITLE_BLINK_INTERVAL_MS = 1000;
3745
+ const TERMINAL_TITLE_WORKING_PREFIX = "● ";
3746
+ const TERMINAL_TITLE_READY_PREFIX = "";
2906
3747
  const REPL_ANIMATION_INTERVAL_MS = 420;
2907
3748
  const TOOL_RESULT_REPLACEMENT_DELAY_MS = 2000;
2908
3749
  const TOKEN_PULSE_MS = 900;