neoctl 0.1.5 → 0.1.7

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 (91) hide show
  1. package/README.md +378 -357
  2. package/dist/agents/local-agent-task.js +2 -1
  3. package/dist/agents/local-agent-task.js.map +1 -1
  4. package/dist/agents/smoke-agents.js +21 -4
  5. package/dist/agents/smoke-agents.js.map +1 -1
  6. package/dist/core/query-engine.d.ts +20 -1
  7. package/dist/core/query-engine.js +86 -12
  8. package/dist/core/query-engine.js.map +1 -1
  9. package/dist/core/query.d.ts +2 -1
  10. package/dist/core/query.js +36 -1
  11. package/dist/core/query.js.map +1 -1
  12. package/dist/core/smoke-core-loop.js +53 -6
  13. package/dist/core/smoke-core-loop.js.map +1 -1
  14. package/dist/index.d.ts +2 -1
  15. package/dist/index.js +2 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/model/communication-logger.d.ts +2 -1
  18. package/dist/model/communication-logger.js +3 -0
  19. package/dist/model/communication-logger.js.map +1 -1
  20. package/dist/model/config.d.ts +10 -4
  21. package/dist/model/config.js +61 -12
  22. package/dist/model/config.js.map +1 -1
  23. package/dist/model/deepseek-adapter.d.ts +29 -0
  24. package/dist/model/deepseek-adapter.js +108 -0
  25. package/dist/model/deepseek-adapter.js.map +1 -0
  26. package/dist/model/env.js +35 -19
  27. package/dist/model/env.js.map +1 -1
  28. package/dist/model/kimi-adapter.d.ts +29 -0
  29. package/dist/model/kimi-adapter.js +108 -0
  30. package/dist/model/kimi-adapter.js.map +1 -0
  31. package/dist/model/model-metadata.json +726 -677
  32. package/dist/model/openai-adapter.d.ts +1 -1
  33. package/dist/model/openai-chat-mapper.d.ts +4 -1
  34. package/dist/model/openai-chat-mapper.js +30 -8
  35. package/dist/model/openai-chat-mapper.js.map +1 -1
  36. package/dist/model/openai-mappers.d.ts +5 -2
  37. package/dist/model/openai-mappers.js +17 -4
  38. package/dist/model/openai-mappers.js.map +1 -1
  39. package/dist/model/openai-responses-mapper.d.ts +1 -1
  40. package/dist/model/openai-responses-mapper.js +2 -1
  41. package/dist/model/openai-responses-mapper.js.map +1 -1
  42. package/dist/model/provider-factory.js +32 -0
  43. package/dist/model/provider-factory.js.map +1 -1
  44. package/dist/model/smoke-deepseek-mapper.d.ts +1 -0
  45. package/dist/model/smoke-deepseek-mapper.js +65 -0
  46. package/dist/model/smoke-deepseek-mapper.js.map +1 -0
  47. package/dist/model/smoke-openai.js +1 -1
  48. package/dist/model/smoke-openai.js.map +1 -1
  49. package/dist/model/smoke-responses-mapper.js +6 -6
  50. package/dist/model/smoke-responses-mapper.js.map +1 -1
  51. package/dist/open-directory.d.ts +1 -0
  52. package/dist/open-directory.js +26 -0
  53. package/dist/open-directory.js.map +1 -0
  54. package/dist/paths.d.ts +7 -0
  55. package/dist/paths.js +12 -0
  56. package/dist/paths.js.map +1 -0
  57. package/dist/repl/commands.d.ts +7 -0
  58. package/dist/repl/commands.js +9 -0
  59. package/dist/repl/commands.js.map +1 -1
  60. package/dist/repl/index.js +700 -60
  61. package/dist/repl/index.js.map +1 -1
  62. package/dist/repl/render.js +0 -2
  63. package/dist/repl/render.js.map +1 -1
  64. package/dist/repl/status-line.d.ts +0 -1
  65. package/dist/repl/status-line.js +27 -34
  66. package/dist/repl/status-line.js.map +1 -1
  67. package/dist/session/session-export.d.ts +33 -0
  68. package/dist/session/session-export.js +351 -0
  69. package/dist/session/session-export.js.map +1 -0
  70. package/dist/session/session-store.js +2 -2
  71. package/dist/session/session-store.js.map +1 -1
  72. package/dist/session/smoke-session.js +22 -1
  73. package/dist/session/smoke-session.js.map +1 -1
  74. package/dist/skills/smoke-skills.js +1 -1
  75. package/dist/tips.d.ts +10 -0
  76. package/dist/tips.js +168 -0
  77. package/dist/tips.js.map +1 -0
  78. package/dist/tools/builtins/search-providers.d.ts +15 -1
  79. package/dist/tools/builtins/search-providers.js +195 -1
  80. package/dist/tools/builtins/search-providers.js.map +1 -1
  81. package/dist/tools/builtins/search-tool.js +2 -2
  82. package/dist/tools/builtins/search-tool.js.map +1 -1
  83. package/dist/tools/smoke-tool-system.js +43 -9
  84. package/dist/tools/smoke-tool-system.js.map +1 -1
  85. package/dist/web/html.d.ts +1 -0
  86. package/dist/web/html.js +697 -0
  87. package/dist/web/html.js.map +1 -0
  88. package/dist/web/index.d.ts +2 -0
  89. package/dist/web/index.js +1465 -0
  90. package/dist/web/index.js.map +1 -0
  91. package/package.json +53 -49
@@ -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,13 +8,12 @@ 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";
@@ -25,7 +25,10 @@ import { createTaskTools } from "../tasks/task-tools.js";
25
25
  import { TaskStore } from "../tasks/task-store.js";
26
26
  import { isModelReasoningArgument, isValidReplCommandLine, parseReplCommand, helpText, replCommandDefinitions } from "./commands.js";
27
27
  import { estimateMarkdownLineCount, markdownRenderKey, MarkdownText } from "./markdown-renderer.js";
28
+ import { writeSessionMarkdownExport } from "../session/session-export.js";
28
29
  import { readClipboard } from "./clipboard.js";
30
+ import { formatTipLine, initialTipIndex, tipAt } from "../tips.js";
31
+ import { openDirectory } from "../open-directory.js";
29
32
  const e = React.createElement;
30
33
  class SessionUsageTracker {
31
34
  totals = emptyUsageTotals();
@@ -114,7 +117,6 @@ async function createRuntime() {
114
117
  const modelGateway = new LoggingModelGateway(createModelGatewayFromProcessEnv(process.env), communicationLogger);
115
118
  const taskStore = new TaskStore();
116
119
  const tools = new ToolRegistry();
117
- tools.register(echoTool);
118
120
  tools.register(editTool);
119
121
  tools.register(writeTool);
120
122
  tools.register(createExecTool({ taskStore }));
@@ -145,6 +147,7 @@ async function createRuntime() {
145
147
  modelGateway,
146
148
  tools,
147
149
  taskNotificationSource,
150
+ commands: replCommandDefinitions.map((command) => command.usage),
148
151
  session: {
149
152
  enabled: process.env.AGENT_SESSION_TRANSCRIPT !== "0",
150
153
  sessionId: process.env.AGENT_SESSION_ID,
@@ -156,18 +159,22 @@ async function createRuntime() {
156
159
  },
157
160
  });
158
161
  await engine.initialize();
162
+ const initialMetrics = await engine.contextMetrics();
159
163
  return {
160
164
  engine,
161
165
  communicationLogger,
166
+ modelGateway,
167
+ agentRuntime,
162
168
  usage: new SessionUsageTracker(),
163
169
  taskStore,
164
- initialMetrics: initialContextMetrics(modelConfig?.model, engine.snapshot().messages, tools.names().length),
170
+ initialMetrics,
165
171
  defaultReasoning: modelConfig?.defaultReasoning,
172
+ envPath: process.env.NEO_ENV_FILE?.trim() ? path.resolve(process.env.NEO_ENV_FILE.trim()) : envLoad.userDotEnvPath,
166
173
  envNotice: envLoad.createdUserDotEnv ? formatCreatedEnvNotice(envLoad.userDotEnvPath) : undefined,
167
174
  };
168
175
  }
169
176
  function formatCreatedEnvNotice(path) {
170
- return `Created default config file: ${path}\nFill MODEL_API_KEY in that file, then restart neo.`;
177
+ 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
178
  }
172
179
  function parseResumeFlag(value) {
173
180
  if (!value)
@@ -198,23 +205,25 @@ function initialContextMetrics(model, messageCount, toolCount) {
198
205
  : undefined,
199
206
  };
200
207
  }
201
- function initialStatus(runtime) {
208
+ function initialStatus(runtime, metrics = runtime.initialMetrics) {
202
209
  return {
203
210
  phase: "ready",
204
211
  metrics: {
205
- ...runtime.initialMetrics,
212
+ ...metrics,
206
213
  messageCount: runtime.engine.snapshot().messages,
207
214
  },
208
215
  streamedOutputTokens: 0,
209
216
  activityTick: 0,
210
217
  };
211
218
  }
212
- function setTerminalTitle(title, dotFilled = true) {
219
+ function resetStatus(runtime) {
220
+ return initialStatus(runtime, initialContextMetrics(runtime.engine.getModelSettings().model, runtime.engine.snapshot().messages, runtime.initialMetrics.toolCount));
221
+ }
222
+ function setTerminalTitle(title, prefix = TERMINAL_TITLE_WORKING_PREFIX) {
213
223
  if (!stdout.isTTY)
214
224
  return;
215
225
  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);
226
+ const decoratedTitle = `${prefix}${safeTitle || "neo"}`.slice(0, 120);
218
227
  stdout.write(`\u001b]0;${decoratedTitle}\u0007`);
219
228
  }
220
229
  function playReadySound() {
@@ -354,12 +363,13 @@ function InkRepl({ runtime }) {
354
363
  const queuedAttachmentsRef = useRef(undefined);
355
364
  const [cursor, setCursor] = useState(0);
356
365
  const [promptPlaceholder, setPromptPlaceholder] = useState(undefined);
366
+ const [tipIndex, setTipIndex] = useState(() => initialTipIndex(runtime.engine.snapshot().session?.sessionId ?? process.cwd()));
357
367
  const [busy, setBusy] = useState(false);
358
368
  const [status, setStatus] = useState(() => initialStatus(runtime));
359
369
  const sessionTitleRef = useRef(sessionTerminalTitle(runtime.engine.snapshot().session));
360
370
  const [backgroundTaskCount, setBackgroundTaskCount] = useState(() => runtime.taskStore.activeCount());
361
371
  const [animationTick, setAnimationTick] = useState(0);
362
- const [terminalTitleDotVisible, setTerminalTitleDotVisible] = useState(true);
372
+ const [terminalTitlePrefix, setTerminalTitlePrefix] = useState(TERMINAL_TITLE_READY_PREFIX);
363
373
  const terminalTitleWorking = isActivePhase(status.phase) || backgroundTaskCount > 0;
364
374
  const [sessionsBrowser, setSessionsBrowser] = useState(undefined);
365
375
  const inputRef = useRef(input);
@@ -377,6 +387,8 @@ function InkRepl({ runtime }) {
377
387
  const [pasteStatus, setPasteStatus] = useState(undefined);
378
388
  const pasteStatusTimerRef = useRef(undefined);
379
389
  const [slashCompletionIndex, setSlashCompletionIndex] = useState(0);
390
+ const [loginForm, setLoginForm] = useState(undefined);
391
+ const loginFormRef = useRef(undefined);
380
392
  useEffect(() => {
381
393
  enableTerminalFocusReporting();
382
394
  enableTerminalMouseReporting();
@@ -398,24 +410,23 @@ function InkRepl({ runtime }) {
398
410
  }, [runtime]);
399
411
  useEffect(() => {
400
412
  if (!terminalTitleWorking) {
401
- setTerminalTitleDotVisible(true);
413
+ setTerminalTitlePrefix(TERMINAL_TITLE_READY_PREFIX);
402
414
  return undefined;
403
415
  }
404
- setTerminalTitleDotVisible(true);
405
- const interval = setInterval(() => setTerminalTitleDotVisible((visible) => !visible), TERMINAL_TITLE_BLINK_INTERVAL_MS);
406
- return () => clearInterval(interval);
416
+ setTerminalTitlePrefix(TERMINAL_TITLE_WORKING_PREFIX);
417
+ return undefined;
407
418
  }, [terminalTitleWorking]);
408
419
  useEffect(() => {
409
420
  const updateTitle = (snapshot) => {
410
421
  sessionTitleRef.current = sessionTerminalTitle(snapshot);
411
- setTerminalTitle(sessionTitleRef.current, terminalTitleDotVisible);
422
+ setTerminalTitle(sessionTitleRef.current, terminalTitlePrefix);
412
423
  };
413
424
  updateTitle(runtime.engine.snapshot().session);
414
425
  return runtime.engine.onSessionTitleChange(updateTitle);
415
- }, [runtime, terminalTitleDotVisible]);
426
+ }, [runtime, terminalTitlePrefix]);
416
427
  useEffect(() => {
417
- setTerminalTitle(sessionTitleRef.current, terminalTitleDotVisible);
418
- }, [terminalTitleDotVisible]);
428
+ setTerminalTitle(sessionTitleRef.current, terminalTitlePrefix);
429
+ }, [terminalTitlePrefix]);
419
430
  const setPromptState = (text, nextCursor, options) => {
420
431
  const safeCursor = Math.max(0, Math.min(nextCursor, text.length));
421
432
  inputRef.current = text;
@@ -442,6 +453,10 @@ function InkRepl({ runtime }) {
442
453
  setSlashCompletionIndex(safeIndex);
443
454
  };
444
455
  const resetSlashCompletionSelection = () => setSlashCompletionSelection(0);
456
+ const setLoginFormState = (next) => {
457
+ loginFormRef.current = next;
458
+ setLoginForm(next);
459
+ };
445
460
  const syncAttachmentsForText = (text) => {
446
461
  const next = attachmentsRef.current.filter((attachment) => text.includes(attachment.label));
447
462
  if (next.length === attachmentsRef.current.length)
@@ -468,9 +483,11 @@ function InkRepl({ runtime }) {
468
483
  }, PASTE_STATUS_DISPLAY_MS);
469
484
  pasteStatusTimerRef.current = timer;
470
485
  };
486
+ const advanceTip = () => setTipIndex((current) => current + 1);
471
487
  const insertAtCursor = (value) => {
472
488
  const currentText = inputRef.current;
473
489
  const currentCursor = cursorRef.current;
490
+ advanceTip();
474
491
  setPromptState(`${currentText.slice(0, currentCursor)}${value}${currentText.slice(currentCursor)}`, currentCursor + value.length);
475
492
  };
476
493
  const insertAttachmentLabel = (attachment) => {
@@ -486,6 +503,10 @@ function InkRepl({ runtime }) {
486
503
  return;
487
504
  }
488
505
  if (payload.type === "image") {
506
+ if (!runtime.engine.canAcceptImageInput()) {
507
+ setPasteStatusMessage("current model does not support image input; image was not added");
508
+ return;
509
+ }
489
510
  const id = ++imageAttachmentCounterRef.current;
490
511
  insertAttachmentLabel({ id, kind: "image", label: `[img#${id}]`, image: payload.image });
491
512
  setPasteStatusMessage(undefined);
@@ -538,9 +559,9 @@ function InkRepl({ runtime }) {
538
559
  const replaceLine = (id, patch) => {
539
560
  setLines((current) => current.map((line) => line.id === id ? { ...line, ...patch, renderedKey: undefined } : line));
540
561
  };
541
- const resumeSnapshot = (snapshot) => {
562
+ const resumeSnapshot = (snapshot, metrics) => {
542
563
  runtime.usage.reset();
543
- setStatus(initialStatus(runtime));
564
+ setStatus(initialStatus(runtime, metrics));
544
565
  resetLinesToHistory(runtime, setLines, lineId);
545
566
  assistantLineId.current = undefined;
546
567
  thinkingLineId.current = undefined;
@@ -693,6 +714,10 @@ function InkRepl({ runtime }) {
693
714
  const trimmed = text.trim();
694
715
  if (!trimmed)
695
716
  return;
717
+ if (submitAttachments.some((attachment) => attachment.kind === "image") && !runtime.engine.canAcceptImageInput()) {
718
+ append({ kind: "error", text: "Current model does not support image input; image attachments were not added to the conversation." });
719
+ return;
720
+ }
696
721
  if (busyRef.current) {
697
722
  if (queuedInputRef.current !== undefined)
698
723
  return;
@@ -798,7 +823,7 @@ function InkRepl({ runtime }) {
798
823
  if (command.type === "reset") {
799
824
  runtime.engine.reset();
800
825
  runtime.usage.reset();
801
- setStatus(initialStatus(runtime));
826
+ setStatus(resetStatus(runtime));
802
827
  append(systemLine("transcript reset"));
803
828
  return;
804
829
  }
@@ -806,18 +831,65 @@ function InkRepl({ runtime }) {
806
831
  append(systemLine(formatReplData({ ...runtime.engine.snapshot(), communicationLog: runtime.communicationLogger.snapshot() }, 12000), EXPANDED_SUMMARY_MAX_LINES));
807
832
  return;
808
833
  }
834
+ if (command.type === "export") {
835
+ setBusyState(true);
836
+ setStatus((current) => ({ ...current, phase: "running", detail: "exporting session", activityTick: current.activityTick + 1 }));
837
+ try {
838
+ const line = await handleExportCommand(command, runtime);
839
+ append(line);
840
+ }
841
+ catch (error) {
842
+ append({ kind: "error", text: error instanceof Error ? error.message : String(error) });
843
+ }
844
+ finally {
845
+ setBusyState(false);
846
+ setStatus((current) => ({ ...current, phase: "ready", detail: undefined, activityTick: current.activityTick + 1 }));
847
+ }
848
+ return;
849
+ }
850
+ if (command.type === "env") {
851
+ const envDirectory = path.dirname(runtime.envPath);
852
+ try {
853
+ await fs.mkdir(envDirectory, { recursive: true });
854
+ await openDirectory(envDirectory);
855
+ append({ kind: "system", title: "System", text: `Opened env directory: ${envDirectory}`, format: "plain", previewStyle: "summary" });
856
+ }
857
+ catch (error) {
858
+ append({ kind: "error", text: `Failed to open env directory ${envDirectory}: ${error instanceof Error ? error.message : String(error)}`, format: "plain" });
859
+ }
860
+ return;
861
+ }
809
862
  if (command.type === "sessions") {
810
863
  await handleSessionsCommand(runtime, setSessionsBrowser, (line) => append(line));
811
864
  return;
812
865
  }
866
+ if (command.type === "login") {
867
+ setSessionsBrowser(undefined);
868
+ setLoginFormState(createLoginFormState(runtime.envPath));
869
+ append(systemLine("Opening provider login. Use ↑/↓ to choose, Enter to continue/save, Esc to cancel."));
870
+ return;
871
+ }
813
872
  if (command.type === "log") {
814
873
  await handleLogCommand(command, runtime, append);
815
874
  return;
816
875
  }
817
876
  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);
877
+ setBusyState(true);
878
+ setStatus((current) => ({ ...current, phase: "running", detail: "saving model settings", activityTick: current.activityTick + 1 }));
879
+ try {
880
+ const line = await handleModelCommand(command, runtime);
881
+ setStatus((current) => ({
882
+ ...current,
883
+ phase: "ready",
884
+ detail: undefined,
885
+ metrics: { ...initialContextMetrics(runtime.engine.getModelSettings().model, runtime.engine.snapshot().messages, runtime.initialMetrics.toolCount), messageCount: runtime.engine.snapshot().messages },
886
+ activityTick: current.activityTick + 1,
887
+ }));
888
+ append(line);
889
+ }
890
+ finally {
891
+ setBusyState(false);
892
+ }
821
893
  return;
822
894
  }
823
895
  if (text.trimStart().startsWith("/")) {
@@ -825,6 +897,10 @@ function InkRepl({ runtime }) {
825
897
  return;
826
898
  }
827
899
  const promptPayload = buildPromptPayload(command.text, submitAttachments);
900
+ if (promptPayload.blocks?.some((block) => block.type === "image") && !runtime.engine.canAcceptImageInput()) {
901
+ append({ kind: "error", text: "Current model does not support image input; image attachments were not added to the conversation." });
902
+ return;
903
+ }
828
904
  append({ kind: "user", text });
829
905
  const abortController = new AbortController();
830
906
  activeAbortController.current = abortController;
@@ -880,6 +956,7 @@ function InkRepl({ runtime }) {
880
956
  }
881
957
  };
882
958
  useEffect(() => {
959
+ setTipIndex(initialTipIndex(runtime.engine.snapshot().session?.sessionId ?? process.cwd()));
883
960
  setLines(initialLines(runtime, lineId));
884
961
  assistantLineId.current = undefined;
885
962
  thinkingLineId.current = undefined;
@@ -888,6 +965,7 @@ function InkRepl({ runtime }) {
888
965
  clearPendingToolResultTimers();
889
966
  setStatus(initialStatus(runtime));
890
967
  setSessionsBrowser(undefined);
968
+ setLoginFormState(undefined);
891
969
  setQueuedPromptState(undefined);
892
970
  setPromptState("", 0);
893
971
  }, [runtime]);
@@ -895,9 +973,13 @@ function InkRepl({ runtime }) {
895
973
  const width = terminalSize.columns;
896
974
  const inputLockedByQueue = busy && queuedInput !== undefined;
897
975
  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);
976
+ const currentTip = tipAt(tipIndex);
977
+ const activePlaceholder = input.length === 0 ? promptPlaceholder ?? currentTip.placeholder : undefined;
978
+ const promptDisplayText = input;
979
+ const promptDisplayCursor = cursor;
980
+ const promptLayoutText = activePlaceholder ? ` ${activePlaceholder}` : promptDisplayText;
981
+ const promptLayoutCursor = activePlaceholder ? 0 : promptDisplayCursor;
982
+ const slashCompletions = inputLockedByQueue || (input.length === 0 && promptPlaceholder !== undefined) || loginForm ? [] : slashCommandCompletions(input, cursor);
901
983
  const visibleSlashCompletionCount = slashCompletions.length;
902
984
  const selectedSlashCompletionIndex = visibleSlashCompletionCount === 0
903
985
  ? 0
@@ -905,7 +987,7 @@ function InkRepl({ runtime }) {
905
987
  if (selectedSlashCompletionIndex !== slashCompletionIndexRef.current) {
906
988
  slashCompletionIndexRef.current = selectedSlashCompletionIndex;
907
989
  }
908
- const promptHeight = promptTextView(promptDisplayText, promptDisplayCursor, width, prompt).length + slashCompletionViewHeight(slashCompletions) + (queuedInput !== undefined ? QUEUED_INPUT_RENDER_ROWS : 0) + (pasteStatus ? 1 : 0);
990
+ const promptHeight = promptTextView(promptLayoutText, promptLayoutCursor, width, prompt).length + slashCompletionViewHeight(slashCompletions) + (queuedInput !== undefined ? QUEUED_INPUT_RENDER_ROWS : 0) + (pasteStatus ? 1 : 0);
909
991
  const firstDynamicLineIndex = lines.findIndex((line) => lineNeedsDynamicRender(line, messageContentWidth(width)));
910
992
  const staticLines = firstDynamicLineIndex === -1 ? lines : lines.slice(0, firstDynamicLineIndex);
911
993
  const dynamicLines = firstDynamicLineIndex === -1 ? [] : lines.slice(firstDynamicLineIndex);
@@ -915,7 +997,8 @@ function InkRepl({ runtime }) {
915
997
  }, 0);
916
998
  const statusRenderRows = STATUS_BAR_RENDER_ROWS + (backgroundTaskCount > 0 ? BACKGROUND_TASK_STATUS_RENDER_ROWS : 0);
917
999
  const sessionsBrowserHeight = sessionsBrowser ? sessionsBrowserViewHeight(sessionsBrowser) : 0;
918
- const liveViewportLines = Math.max(MIN_LIVE_VIEWPORT_LINES, terminalSize.rows - promptHeight - statusRenderRows - sessionsBrowserHeight - dynamicMarginOverhead - 1);
1000
+ const loginFormHeight = loginForm ? loginFormViewHeight(loginForm) : 0;
1001
+ const liveViewportLines = Math.max(MIN_LIVE_VIEWPORT_LINES, terminalSize.rows - promptHeight - statusRenderRows - sessionsBrowserHeight - loginFormHeight - dynamicMarginOverhead - 1);
919
1002
  useInput((value, key) => {
920
1003
  if (isTerminalFocusInSequence(value)) {
921
1004
  terminalFocusedRef.current = true;
@@ -963,6 +1046,10 @@ function InkRepl({ runtime }) {
963
1046
  restoreQueuedPromptToEditor();
964
1047
  return;
965
1048
  }
1049
+ if (loginFormRef.current) {
1050
+ handleLoginFormInput(value, key, loginFormRef.current, setLoginFormState, runtime, append, setStatus);
1051
+ return;
1052
+ }
966
1053
  if (sessionsBrowser) {
967
1054
  if (key.escape) {
968
1055
  setSessionsBrowser(undefined);
@@ -988,9 +1075,9 @@ function InkRepl({ runtime }) {
988
1075
  const selected = sessionsBrowser.sessions[sessionAbsoluteIndex(sessionsBrowser)];
989
1076
  if (selected) {
990
1077
  setSessionsBrowser(undefined);
991
- void handleResumeCommand(selected.sessionId, runtime, (line) => append(line)).then((resumed) => {
992
- if (resumed)
993
- resumeSnapshot(resumed);
1078
+ void handleResumeCommand(selected.sessionId, runtime, (line) => append(line)).then((result) => {
1079
+ if (result)
1080
+ resumeSnapshot(result.snapshot, result.metrics);
994
1081
  });
995
1082
  }
996
1083
  return;
@@ -1023,6 +1110,10 @@ function InkRepl({ runtime }) {
1023
1110
  if (key.backspace || key.delete) {
1024
1111
  const currentText = inputRef.current;
1025
1112
  const currentCursor = cursorRef.current;
1113
+ if (currentText.length === 0) {
1114
+ setTipIndex((current) => current + 1);
1115
+ return;
1116
+ }
1026
1117
  if (currentCursor > 0) {
1027
1118
  setPromptState(`${currentText.slice(0, currentCursor - 1)}${currentText.slice(currentCursor)}`, currentCursor - 1);
1028
1119
  }
@@ -1034,6 +1125,10 @@ function InkRepl({ runtime }) {
1034
1125
  setSlashCompletionSelection((slashCompletionIndexRef.current + completionCount - SLASH_COMPLETION_PAGE_SIZE) % completionCount);
1035
1126
  return;
1036
1127
  }
1128
+ if (inputRef.current.length === 0) {
1129
+ setTipIndex((current) => current - 1);
1130
+ return;
1131
+ }
1037
1132
  setPromptState(inputRef.current, cursorRef.current - 1);
1038
1133
  return;
1039
1134
  }
@@ -1043,18 +1138,32 @@ function InkRepl({ runtime }) {
1043
1138
  setSlashCompletionSelection((slashCompletionIndexRef.current + SLASH_COMPLETION_PAGE_SIZE) % completionCount);
1044
1139
  return;
1045
1140
  }
1141
+ if (inputRef.current.length === 0) {
1142
+ setTipIndex((current) => current + 1);
1143
+ return;
1144
+ }
1046
1145
  setPromptState(inputRef.current, cursorRef.current + 1);
1047
1146
  return;
1048
1147
  }
1049
1148
  if (key.home) {
1050
- setPromptState(inputRef.current, 0);
1149
+ if (inputRef.current.length === 0)
1150
+ setTipIndex(0);
1151
+ else
1152
+ setPromptState(inputRef.current, 0);
1051
1153
  return;
1052
1154
  }
1053
1155
  if (key.end) {
1054
- setPromptState(inputRef.current, inputRef.current.length);
1156
+ if (inputRef.current.length === 0)
1157
+ setTipIndex((current) => current + 1);
1158
+ else
1159
+ setPromptState(inputRef.current, inputRef.current.length);
1055
1160
  return;
1056
1161
  }
1057
1162
  if (key.upArrow) {
1163
+ if (inputRef.current.length === 0 && history.current.length === 0) {
1164
+ setTipIndex((current) => current - 1);
1165
+ return;
1166
+ }
1058
1167
  const completionCount = slashCompletionSelectableCount(inputRef.current, cursorRef.current);
1059
1168
  if (completionCount > 0) {
1060
1169
  setSlashCompletionSelection((slashCompletionIndexRef.current + completionCount - 1) % completionCount);
@@ -1068,6 +1177,10 @@ function InkRepl({ runtime }) {
1068
1177
  return;
1069
1178
  }
1070
1179
  if (key.downArrow) {
1180
+ if (inputRef.current.length === 0 && historyIndexRef.current === undefined) {
1181
+ setTipIndex((current) => current + 1);
1182
+ return;
1183
+ }
1071
1184
  const completionCount = slashCompletionSelectableCount(inputRef.current, cursorRef.current);
1072
1185
  if (completionCount > 0) {
1073
1186
  setSlashCompletionSelection((slashCompletionIndexRef.current + 1) % completionCount);
@@ -1089,6 +1202,10 @@ function InkRepl({ runtime }) {
1089
1202
  }
1090
1203
  if (key.tab) {
1091
1204
  const currentText = inputRef.current;
1205
+ if (currentText.length === 0) {
1206
+ setTipIndex((current) => current + 1);
1207
+ return;
1208
+ }
1092
1209
  const currentCursor = cursorRef.current;
1093
1210
  const completions = slashCommandCompletions(currentText, currentCursor);
1094
1211
  const completion = completions[Math.min(slashCompletionIndexRef.current, completions.length - 1)];
@@ -1100,9 +1217,10 @@ function InkRepl({ runtime }) {
1100
1217
  }
1101
1218
  if (value && !key.ctrl && !key.meta) {
1102
1219
  insertAtCursor(value);
1220
+ return;
1103
1221
  }
1104
1222
  });
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 }));
1223
+ 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 }), 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, ghostText: activePlaceholder, width, prompt, slashCompletions, selectedSlashCompletionIndex, attachments }));
1106
1224
  }
1107
1225
  const MessageList = React.memo(function MessageList({ lines, width, liveMaxLines, lineIndexOffset = 0, onMarkdownRenderComplete }) {
1108
1226
  const contentWidth = messageContentWidth(width);
@@ -1128,9 +1246,17 @@ function MessageLine({ line, width, contentWidth = messageContentWidth(width), t
1128
1246
  const display = displayWindowForLine(line, summaryWidth, line.live ? liveMaxLines : undefined);
1129
1247
  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
1248
  }
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)));
1249
+ const useRoleMarker = !titleProvidesToolMarker(line);
1250
+ const lineWidth = useRoleMarker ? contentWidth : toolWidth;
1251
+ const clipPendingMarkdown = !line.live && onMarkdownRenderComplete !== undefined && lineNeedsDynamicRender(line, lineWidth);
1252
+ const display = displayWindowForLine(line, lineWidth, line.live || clipPendingMarkdown ? liveMaxLines : undefined);
1253
+ const contentNodes = [];
1254
+ if (line.title)
1255
+ contentNodes.push(renderBlockTitle(line));
1256
+ if (line.bodyTitle)
1257
+ contentNodes.push(e(Text, { key: `body-title-${line.id}`, bold: true }, line.bodyTitle));
1258
+ contentNodes.push(...renderDisplayText(line, lineWidth, display.maxLines, display.skipTop, onMarkdownRenderComplete));
1259
+ return e(Box, { flexDirection: "row" }, useRoleMarker ? e(Text, { color: markerColorForKind(line.kind) }, messageRoleMarker(line.kind)) : null, e(Box, { flexDirection: "column", width: lineWidth }, ...contentNodes));
1134
1260
  }
1135
1261
  function displayWindowForLine(line, width, maxLines) {
1136
1262
  if (maxLines === undefined)
@@ -1200,12 +1326,21 @@ function summaryTitle(line) {
1200
1326
  function summaryUsesRoleMarker(line) {
1201
1327
  return line.previewStyle === "summary" && (line.kind === "system" || line.kind === "meta");
1202
1328
  }
1329
+ function titleProvidesToolMarker(line) {
1330
+ return line.kind === "tool" && !!line.title && (line.title.startsWith("◇ ") || line.title.startsWith("◆ "));
1331
+ }
1203
1332
  function titleStatusMarker(status) {
1204
1333
  return status === "success" ? "✓" : "✗";
1205
1334
  }
1206
1335
  function titleStatusColor(status) {
1207
1336
  return status === "success" ? "green" : "red";
1208
1337
  }
1338
+ function renderBlockTitle(line) {
1339
+ const title = line.title ?? titleForKind(line.kind);
1340
+ if (!line.titleStatus)
1341
+ return e(Text, { key: `title-${line.id}`, color: colorForKind(line.kind), bold: true }, title);
1342
+ 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)));
1343
+ }
1209
1344
  function renderSummaryBlock(line, width, maxLines, skipTop = 0) {
1210
1345
  const allPreviewLines = renderSummaryLines(line, width);
1211
1346
  const preview = clipStrings(allPreviewLines, maxLines, skipTop);
@@ -1451,7 +1586,7 @@ function renderCompactStatusSegments(status, animationTick, width, inputTokens,
1451
1586
  const context = renderContextParts(status.metrics);
1452
1587
  const fixedText = [
1453
1588
  phaseText,
1454
- `ctx ${context.used} / ${context.limit} (${context.percent})`,
1589
+ `ctx ${context.percent} of ${context.limit}`,
1455
1590
  `↑ ${inputValue}`,
1456
1591
  `↓ ${outputValue}`,
1457
1592
  ].join(STATUS_SEPARATOR);
@@ -1469,8 +1604,8 @@ function renderCompactStatusSegments(status, animationTick, width, inputTokens,
1469
1604
  { text: model },
1470
1605
  statusDividerSegment(),
1471
1606
  statusLabelSegment("ctx"),
1472
- { text: ` ${context.used} / ${context.limit}` },
1473
- { text: ` (${context.percent})`, color: contextColor(status.metrics) },
1607
+ { text: ` ${context.percent}`, color: contextColor(status.metrics) },
1608
+ { text: ` of ${context.limit}` },
1474
1609
  statusDividerSegment(),
1475
1610
  statusLabelSegment("↑", tokenInputColor),
1476
1611
  { text: ` ${inputValue}` },
@@ -1616,10 +1751,16 @@ function selectedSlashCommandCompletion(text, cursor, selectedIndex) {
1616
1751
  return undefined;
1617
1752
  return completions[Math.max(0, Math.min(selectedIndex, completions.length - 1))];
1618
1753
  }
1619
- function PromptLine({ text, cursor, busy, locked, placeholder = false, width, prompt, slashCompletions, selectedSlashCompletionIndex, attachments }) {
1620
- const visualLines = promptTextView(text, cursor, width, prompt);
1754
+ function PromptLine({ text, cursor, busy, locked, placeholder = false, ghostText, width, prompt, slashCompletions, selectedSlashCompletionIndex, attachments }) {
1755
+ const displayText = text.length === 0 && ghostText ? ` ${ghostText}` : text;
1756
+ const displayCursor = text.length === 0 && ghostText ? 0 : cursor;
1757
+ const visualLines = promptTextView(displayText, displayCursor, width, prompt);
1621
1758
  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 }));
1759
+ return e(Box, { flexDirection: "column" }, ...visualLines.map((line, index) => {
1760
+ const isGhostLine = text.length === 0 && ghostText !== undefined;
1761
+ const afterColor = isGhostLine ? "gray" : inputColor;
1762
+ 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`));
1763
+ }), ...SlashCompletionLines({ completions: slashCompletions, width, prompt, selectedIndex: selectedSlashCompletionIndex }));
1623
1764
  }
1624
1765
  function PasteStatusLine({ text, width: terminalWidth }) {
1625
1766
  const width = statusBarWidth(terminalWidth);
@@ -1678,17 +1819,40 @@ function SlashCompletionLines({ completions, width, prompt, selectedIndex }) {
1678
1819
  e(Text, { key: "slash-completion-footer", color: "gray" }, fitToWidth(footer, contentWidth)),
1679
1820
  ].map((line, index) => e(Box, { key: `slash-completion-line-${index}`, height: 1, overflow: "hidden" }, e(Text, { color: "gray" }, " ".repeat(prompt.length)), line));
1680
1821
  }
1681
- function handleModelCommand(command, runtime) {
1822
+ async function handleModelCommand(command, runtime) {
1682
1823
  const current = runtime.engine.getModelSettings();
1683
1824
  const nextModel = command.model ?? current.model;
1684
1825
  const validationError = validateModelReasoningArgument(nextModel, command.reasoning);
1685
1826
  if (validationError)
1686
1827
  return { kind: "error", text: validationError };
1687
1828
  const reasoningUpdate = resolveModelReasoningUpdate(command.reasoning, current.reasoning, nextModel, command.model !== undefined);
1688
- if (command.model !== undefined || command.reasoning !== undefined) {
1829
+ const changed = command.model !== undefined || command.reasoning !== undefined;
1830
+ if (changed) {
1689
1831
  runtime.engine.setModel(nextModel, reasoningUpdate.reasoning, reasoningUpdate.update);
1832
+ try {
1833
+ const { providerChanged } = await persistModelCommandSettings(runtime, command, reasoningUpdate);
1834
+ if (providerChanged) {
1835
+ const config = readModelProviderConfig(process.env);
1836
+ if (config) {
1837
+ const innerGateway = createModelGatewayFromConfig(config);
1838
+ runtime.modelGateway.setInner(innerGateway);
1839
+ runtime.agentRuntime.modelGateway = runtime.modelGateway;
1840
+ runtime.engine.setModelProvider({
1841
+ modelGateway: runtime.modelGateway,
1842
+ model: config.model,
1843
+ fallbackModel: config.fallbackModel,
1844
+ reasoning: config.defaultReasoning,
1845
+ });
1846
+ runtime.defaultReasoning = config.defaultReasoning;
1847
+ }
1848
+ }
1849
+ }
1850
+ catch (error) {
1851
+ return { kind: "error", text: `Model settings changed for this session, but saving to ${runtime.envPath} failed: ${error instanceof Error ? error.message : String(error)}` };
1852
+ }
1690
1853
  }
1691
- return systemLine(formatModelSettings(runtime.engine.getModelSettings(), runtime.defaultReasoning));
1854
+ const settings = formatModelSettings(runtime.engine.getModelSettings(), runtime.defaultReasoning);
1855
+ return systemLine(changed ? `${settings}\nSaved to ${runtime.envPath}` : settings);
1692
1856
  }
1693
1857
  function resolveModelReasoningUpdate(value, current, modelId, modelChanged) {
1694
1858
  if (value === "off")
@@ -1702,6 +1866,62 @@ function resolveModelReasoningUpdate(value, current, modelId, modelChanged) {
1702
1866
  }
1703
1867
  return { reasoning: current, update: false };
1704
1868
  }
1869
+ async function persistModelCommandSettings(runtime, command, reasoningUpdate) {
1870
+ const currentProvider = currentModelProvider();
1871
+ let targetProvider = currentProvider;
1872
+ const updates = {};
1873
+ if (command.model !== undefined) {
1874
+ const metadata = findModelMetadata(command.model);
1875
+ if (metadata) {
1876
+ const modelProvider = parseLoginProvider(metadata.provider);
1877
+ if (modelProvider) {
1878
+ targetProvider = modelProvider;
1879
+ if (targetProvider !== currentProvider)
1880
+ updates.MODEL_PROVIDER = targetProvider;
1881
+ }
1882
+ }
1883
+ updates[modelEnvKeyForProvider(targetProvider)] = command.model.trim() || undefined;
1884
+ }
1885
+ if (command.reasoning !== undefined || reasoningUpdate.update) {
1886
+ updates.MODEL_REASONING_EFFORT = envValueForReasoning(reasoningUpdate.reasoning);
1887
+ updates.MODEL_REASONING_SUMMARY = undefined;
1888
+ }
1889
+ if (Object.keys(updates).length === 0)
1890
+ return { providerChanged: false };
1891
+ await writeEnvUpdates(runtime.envPath, updates);
1892
+ applyEnvUpdatesToProcess(updates);
1893
+ runtime.defaultReasoning = reasoningUpdate.update ? reasoningUpdate.reasoning : runtime.defaultReasoning;
1894
+ return { providerChanged: targetProvider !== currentProvider };
1895
+ }
1896
+ function currentModelProvider() {
1897
+ return parseLoginProvider(process.env.MODEL_PROVIDER) ?? "openai";
1898
+ }
1899
+ function modelEnvKeyForProvider(provider) {
1900
+ if (provider === "deepseek")
1901
+ return "DEEPSEEK_MODEL";
1902
+ if (provider === "kimi")
1903
+ return "KIMI_MODEL";
1904
+ return "OPENAI_MODEL";
1905
+ }
1906
+ function envValueForReasoning(reasoning) {
1907
+ if (reasoning === null)
1908
+ return "off";
1909
+ return reasoning?.effort;
1910
+ }
1911
+ async function writeEnvUpdates(envPath, updates, removeKeys = []) {
1912
+ await fs.mkdir(path.dirname(envPath), { recursive: true });
1913
+ const existing = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
1914
+ const next = updateEnvContent(existing, updates, removeKeys);
1915
+ await fs.writeFile(envPath, next, "utf8");
1916
+ }
1917
+ function applyEnvUpdatesToProcess(updates) {
1918
+ for (const [key, value] of Object.entries(updates)) {
1919
+ if (value === undefined)
1920
+ delete process.env[key];
1921
+ else
1922
+ process.env[key] = value;
1923
+ }
1924
+ }
1705
1925
  function validateModelReasoningArgument(modelId, reasoning) {
1706
1926
  if (!reasoning || reasoning === "default" || reasoning === "off")
1707
1927
  return undefined;
@@ -1933,9 +2153,25 @@ async function handleSessionsCommand(runtime, setBrowser, append) {
1933
2153
  }
1934
2154
  setBrowser({ sessions, pageSize: SESSIONS_DEFAULT_PAGE_SIZE, pageIndex: 0, selectedIndex: 0 });
1935
2155
  }
2156
+ async function handleExportCommand(command, runtime) {
2157
+ const snapshot = runtime.engine.snapshot();
2158
+ if (!snapshot.session)
2159
+ throw new Error("session transcripts are disabled; cannot export current session");
2160
+ const promptSnapshot = await runtime.engine.promptExportSnapshot();
2161
+ const result = await writeSessionMarkdownExport({
2162
+ outputPath: command.path,
2163
+ session: snapshot.session,
2164
+ agentId: snapshot.agentId,
2165
+ promptSnapshot,
2166
+ engineSnapshot: { ...snapshot, communicationLog: runtime.communicationLogger.snapshot(), usage: runtime.usage.snapshot() },
2167
+ });
2168
+ return systemLine(`Exported current session to ${result.outputPath}\nEntries: ${result.entries}\nMessages: ${result.messages}\nBytes: ${result.bytes}`);
2169
+ }
1936
2170
  async function handleResumeCommand(sessionId, runtime, append) {
1937
2171
  try {
1938
- return await runtime.engine.resumeSession(sessionId);
2172
+ const snapshot = await runtime.engine.resumeSession(sessionId);
2173
+ const metrics = await runtime.engine.contextMetrics();
2174
+ return { snapshot, metrics };
1939
2175
  }
1940
2176
  catch (error) {
1941
2177
  append({ kind: "error", text: error instanceof Error ? error.message : String(error) });
@@ -1976,11 +2212,11 @@ function initialLines(runtime, lineId) {
1976
2212
  ? ` Session: ${session.sessionId}${session.resumedMessages > 0 ? ` (${session.resumedMessages} resumed messages)` : ""}.`
1977
2213
  : "";
1978
2214
  const lines = [
1979
- { id: 0, kind: "system", title: "System", text: `Interactive UI enabled. Type /help for commands.${suffix}`, previewStyle: "summary" },
2215
+ { 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
2216
  ];
1981
2217
  lineId.current = 0;
1982
2218
  if (runtime.envNotice)
1983
- lines.push({ id: ++lineId.current, kind: "system", title: "Config", text: runtime.envNotice, previewStyle: "summary" });
2219
+ lines.push({ id: ++lineId.current, kind: "system", title: "Config", text: runtime.envNotice, format: "plain", previewStyle: "summary" });
1984
2220
  for (const line of restoredHistoryLines(runtime))
1985
2221
  lines.push({ id: ++lineId.current, ...line });
1986
2222
  return lines;
@@ -1999,6 +2235,71 @@ function restoredHistoryLines(runtime) {
1999
2235
  }
2000
2236
  return lines;
2001
2237
  }
2238
+ const LOGIN_PROVIDERS = ["openai", "deepseek", "kimi"];
2239
+ const SHARED_LOGIN_FIELDS = [
2240
+ { key: "reasoningEffort", label: "Reasoning effort", envKey: "MODEL_REASONING_EFFORT", scope: "shared", options: ["", "off", "none", "minimal", "low", "medium", "high", "xhigh", "max"] },
2241
+ { key: "reasoningSummary", label: "Reasoning summary", envKey: "MODEL_REASONING_SUMMARY", scope: "shared", options: ["", "auto", "concise", "detailed"] },
2242
+ { key: "maxOutputTokens", label: "Max output tokens", envKey: "MODEL_MAX_OUTPUT_TOKENS", scope: "shared", placeholder: "800" },
2243
+ { key: "timeoutMs", label: "Timeout ms", envKey: "MODEL_TIMEOUT_MS", scope: "shared", placeholder: "120000" },
2244
+ { key: "streamIdleTimeoutMs", label: "Stream idle timeout ms", envKey: "MODEL_STREAM_IDLE_TIMEOUT_MS", scope: "shared", placeholder: "120000" },
2245
+ { key: "maxRetries", label: "Max retries", envKey: "MODEL_MAX_RETRIES", scope: "shared", placeholder: "2" },
2246
+ ];
2247
+ const LOGIN_FIELD_DEFINITIONS = {
2248
+ openai: [
2249
+ { key: "apiKey", label: "API key", envKey: "OPENAI_API_KEY", scope: "provider", required: true, secret: true, placeholder: "sk-..." },
2250
+ { key: "baseUrl", label: "Base URL", envKey: "OPENAI_BASE_URL", scope: "provider", placeholder: "https://api.openai.com" },
2251
+ { key: "model", label: "Model", envKey: "OPENAI_MODEL", scope: "provider", required: true, placeholder: "gpt-5.5" },
2252
+ { key: "fallbackModel", label: "Fallback model", envKey: "OPENAI_FALLBACK_MODEL", scope: "provider" },
2253
+ { key: "endpoint", label: "Endpoint", envKey: "OPENAI_ENDPOINT", scope: "provider", placeholder: "auto", options: ["auto", "responses", "chat"] },
2254
+ ...SHARED_LOGIN_FIELDS,
2255
+ ],
2256
+ deepseek: [
2257
+ { key: "apiKey", label: "API key", envKey: "DEEPSEEK_API_KEY", scope: "provider", required: true, secret: true, placeholder: "sk-..." },
2258
+ { key: "baseUrl", label: "Base URL", envKey: "DEEPSEEK_BASE_URL", scope: "provider", placeholder: "https://api.deepseek.com" },
2259
+ { key: "model", label: "Model", envKey: "DEEPSEEK_MODEL", scope: "provider", required: true, placeholder: "deepseek-chat" },
2260
+ { key: "fallbackModel", label: "Fallback model", envKey: "DEEPSEEK_FALLBACK_MODEL", scope: "provider" },
2261
+ ...SHARED_LOGIN_FIELDS,
2262
+ ],
2263
+ kimi: [
2264
+ { key: "apiKey", label: "API key", envKey: "KIMI_API_KEY", scope: "provider", required: true, secret: true, placeholder: "sk-..." },
2265
+ { key: "baseUrl", label: "Base URL", envKey: "KIMI_BASE_URL", scope: "provider", placeholder: "https://api.moonshot.cn/v1" },
2266
+ { key: "model", label: "Model", envKey: "KIMI_MODEL", scope: "provider", required: true, placeholder: "kimi-k2.6" },
2267
+ { key: "fallbackModel", label: "Fallback model", envKey: "KIMI_FALLBACK_MODEL", scope: "provider" },
2268
+ ...SHARED_LOGIN_FIELDS,
2269
+ ],
2270
+ };
2271
+ const DEPRECATED_MODEL_ENV_KEYS = [
2272
+ "MODEL_API_KEY",
2273
+ "MODEL_BASE_URL",
2274
+ "MODEL_ID",
2275
+ "MODEL_FALLBACK_ID",
2276
+ "MODEL_ENDPOINT",
2277
+ "OPENAI_PROVIDER",
2278
+ "OPENAI_REASONING_EFFORT",
2279
+ "OPENAI_REASONING_SUMMARY",
2280
+ "OPENAI_MAX_OUTPUT_TOKENS",
2281
+ "OPENAI_TIMEOUT_MS",
2282
+ "OPENAI_STREAM_IDLE_TIMEOUT_MS",
2283
+ "OPENAI_MAX_RETRIES",
2284
+ "DEEPSEEK_REASONING_EFFORT",
2285
+ "DEEPSEEK_REASONING_SUMMARY",
2286
+ "DEEPSEEK_MAX_OUTPUT_TOKENS",
2287
+ "DEEPSEEK_TIMEOUT_MS",
2288
+ "DEEPSEEK_STREAM_IDLE_TIMEOUT_MS",
2289
+ "DEEPSEEK_MAX_RETRIES",
2290
+ "KIMI_REASONING_EFFORT",
2291
+ "KIMI_REASONING_SUMMARY",
2292
+ "KIMI_MAX_OUTPUT_TOKENS",
2293
+ "KIMI_TIMEOUT_MS",
2294
+ "KIMI_STREAM_IDLE_TIMEOUT_MS",
2295
+ "KIMI_MAX_RETRIES",
2296
+ "MOONSHOT_REASONING_EFFORT",
2297
+ "MOONSHOT_REASONING_SUMMARY",
2298
+ "MOONSHOT_MAX_OUTPUT_TOKENS",
2299
+ "MOONSHOT_TIMEOUT_MS",
2300
+ "MOONSHOT_STREAM_IDLE_TIMEOUT_MS",
2301
+ "MOONSHOT_MAX_RETRIES",
2302
+ ];
2002
2303
  function sessionsPageCount(state) {
2003
2304
  return Math.max(1, Math.ceil(state.sessions.length / state.pageSize));
2004
2305
  }
@@ -2048,6 +2349,342 @@ function SessionsBrowser({ state, width }) {
2048
2349
  }, row.numberPrefix), row.rest);
2049
2350
  }), e(Text, { color: "gray" }, fitToWidth(footer, contentWidth)));
2050
2351
  }
2352
+ function handleLoginFormInput(value, key, state, setLoginFormState, runtime, append, setStatus) {
2353
+ if (key.escape) {
2354
+ if (state.step === "fields")
2355
+ setLoginFormState({ ...state, step: "provider" });
2356
+ else {
2357
+ setLoginFormState(undefined);
2358
+ append(systemLine("Login cancelled."));
2359
+ }
2360
+ return;
2361
+ }
2362
+ if (state.step === "provider") {
2363
+ if (key.upArrow) {
2364
+ setLoginFormState(moveLoginProviderSelection(state, -1));
2365
+ return;
2366
+ }
2367
+ if (key.downArrow) {
2368
+ setLoginFormState(moveLoginProviderSelection(state, 1));
2369
+ return;
2370
+ }
2371
+ if (key.return) {
2372
+ const provider = state.providers[state.selectedProviderIndex] ?? state.provider;
2373
+ setLoginFormState({ ...loginFormForProvider(provider, state.envPath), step: "fields" });
2374
+ return;
2375
+ }
2376
+ return;
2377
+ }
2378
+ const fields = LOGIN_FIELD_DEFINITIONS[state.provider];
2379
+ const field = fields[state.selectedFieldIndex];
2380
+ if (!field)
2381
+ return;
2382
+ if (key.upArrow) {
2383
+ setLoginFormState(moveLoginFieldSelection(state, -1));
2384
+ return;
2385
+ }
2386
+ if (key.downArrow) {
2387
+ setLoginFormState(moveLoginFieldSelection(state, 1));
2388
+ return;
2389
+ }
2390
+ if (key.leftArrow) {
2391
+ setLoginFormState({ ...state, cursor: Math.max(0, state.cursor - 1) });
2392
+ return;
2393
+ }
2394
+ if (key.rightArrow) {
2395
+ const current = state.values[field.key] ?? "";
2396
+ setLoginFormState({ ...state, cursor: Math.min(current.length, state.cursor + 1) });
2397
+ return;
2398
+ }
2399
+ if (key.tab && field.options?.length) {
2400
+ setLoginFormState(cycleLoginFieldOption(state, field));
2401
+ return;
2402
+ }
2403
+ if (key.backspace || key.delete) {
2404
+ setLoginFormState(deleteLoginFieldCharacter(state, field));
2405
+ return;
2406
+ }
2407
+ if (key.return) {
2408
+ void submitLoginForm(state, runtime, append, setLoginFormState, setStatus);
2409
+ return;
2410
+ }
2411
+ if (value && !key.ctrl && !key.meta) {
2412
+ setLoginFormState(insertLoginFieldText(state, field, value));
2413
+ }
2414
+ }
2415
+ function moveLoginProviderSelection(state, delta) {
2416
+ const selectedProviderIndex = (state.selectedProviderIndex + delta + state.providers.length) % state.providers.length;
2417
+ return { ...state, selectedProviderIndex, provider: state.providers[selectedProviderIndex] ?? state.provider };
2418
+ }
2419
+ function moveLoginFieldSelection(state, delta) {
2420
+ const fields = LOGIN_FIELD_DEFINITIONS[state.provider];
2421
+ const selectedFieldIndex = (state.selectedFieldIndex + delta + fields.length) % fields.length;
2422
+ const field = fields[selectedFieldIndex];
2423
+ return { ...state, selectedFieldIndex, cursor: field ? (state.values[field.key] ?? "").length : 0 };
2424
+ }
2425
+ function cycleLoginFieldOption(state, field) {
2426
+ const options = field.options ?? [];
2427
+ const current = state.values[field.key] ?? "";
2428
+ const index = options.indexOf(current);
2429
+ const next = options[(index + 1 + options.length) % options.length] ?? "";
2430
+ return { ...state, values: { ...state.values, [field.key]: next }, cursor: next.length };
2431
+ }
2432
+ function insertLoginFieldText(state, field, value) {
2433
+ const current = state.values[field.key] ?? "";
2434
+ const cursor = Math.max(0, Math.min(state.cursor, current.length));
2435
+ const next = `${current.slice(0, cursor)}${value}${current.slice(cursor)}`;
2436
+ return { ...state, values: { ...state.values, [field.key]: next }, cursor: cursor + value.length };
2437
+ }
2438
+ function deleteLoginFieldCharacter(state, field) {
2439
+ const current = state.values[field.key] ?? "";
2440
+ const cursor = Math.max(0, Math.min(state.cursor, current.length));
2441
+ if (cursor <= 0)
2442
+ return state;
2443
+ const next = `${current.slice(0, cursor - 1)}${current.slice(cursor)}`;
2444
+ return { ...state, values: { ...state.values, [field.key]: next }, cursor: cursor - 1 };
2445
+ }
2446
+ async function submitLoginForm(state, runtime, append, setLoginFormState, setStatus) {
2447
+ const validationError = validateLoginForm(state);
2448
+ if (validationError) {
2449
+ append({ kind: "error", text: validationError });
2450
+ return;
2451
+ }
2452
+ try {
2453
+ await saveLoginFormToEnv(state);
2454
+ applyLoginFormToProcessEnv(state);
2455
+ const config = readModelProviderConfig(process.env);
2456
+ if (!config)
2457
+ throw new Error("Saved provider config could not be loaded from environment.");
2458
+ const innerGateway = createModelGatewayFromConfig(config);
2459
+ runtime.modelGateway.setInner(innerGateway);
2460
+ runtime.agentRuntime.modelGateway = runtime.modelGateway;
2461
+ runtime.engine.setModelProvider({
2462
+ modelGateway: runtime.modelGateway,
2463
+ model: config.model,
2464
+ fallbackModel: config.fallbackModel,
2465
+ reasoning: config.defaultReasoning,
2466
+ });
2467
+ runtime.defaultReasoning = config.defaultReasoning;
2468
+ setStatus((current) => ({
2469
+ ...current,
2470
+ metrics: { ...initialContextMetrics(config.model, runtime.engine.snapshot().messages, runtime.initialMetrics.toolCount), messageCount: runtime.engine.snapshot().messages },
2471
+ }));
2472
+ setLoginFormState(undefined);
2473
+ append(systemLine(`Saved ${state.provider} login to ${state.envPath}\n${formatModelSettings(runtime.engine.getModelSettings(), runtime.defaultReasoning)}`, EXPANDED_SUMMARY_MAX_LINES));
2474
+ }
2475
+ catch (error) {
2476
+ append({ kind: "error", text: `Login save failed: ${error instanceof Error ? error.message : String(error)}` });
2477
+ }
2478
+ }
2479
+ function validateLoginForm(state) {
2480
+ for (const field of LOGIN_FIELD_DEFINITIONS[state.provider]) {
2481
+ const value = (state.values[field.key] ?? "").trim();
2482
+ if (field.required && !value)
2483
+ return `${field.label} is required.`;
2484
+ if (field.options?.length && value && !field.options.includes(value))
2485
+ return `${field.label} must be one of: ${field.options.filter(Boolean).join(", ")}`;
2486
+ }
2487
+ for (const fieldKey of ["maxOutputTokens", "timeoutMs", "streamIdleTimeoutMs", "maxRetries"]) {
2488
+ const value = state.values[fieldKey]?.trim();
2489
+ if (value && !Number.isFinite(Number(value)))
2490
+ return `${fieldKey} must be a number.`;
2491
+ }
2492
+ return undefined;
2493
+ }
2494
+ function createLoginFormState(envPath = getUserDotEnvPath()) {
2495
+ const env = parseEnvFileSafe(envPath);
2496
+ const currentProvider = parseLoginProvider(env.MODEL_PROVIDER ?? process.env.MODEL_PROVIDER) ?? guessLoginProvider(env);
2497
+ return loginFormForProvider(currentProvider, envPath, env);
2498
+ }
2499
+ function loginFormForProvider(provider, envPath, env = parseEnvFileSafe(envPath)) {
2500
+ const selectedProviderIndex = Math.max(0, LOGIN_PROVIDERS.indexOf(provider));
2501
+ return {
2502
+ step: "provider",
2503
+ providers: LOGIN_PROVIDERS,
2504
+ selectedProviderIndex,
2505
+ provider,
2506
+ selectedFieldIndex: 0,
2507
+ cursor: 0,
2508
+ values: loginValuesForProvider(provider, env),
2509
+ envPath,
2510
+ };
2511
+ }
2512
+ function loginValuesForProvider(provider, env) {
2513
+ const values = {};
2514
+ for (const field of LOGIN_FIELD_DEFINITIONS[provider]) {
2515
+ values[field.key] = env[field.envKey] ?? "";
2516
+ }
2517
+ if (provider === "kimi") {
2518
+ values.apiKey ||= env.MOONSHOT_API_KEY ?? process.env.MOONSHOT_API_KEY ?? "";
2519
+ values.baseUrl ||= env.MOONSHOT_BASE_URL ?? process.env.MOONSHOT_BASE_URL ?? "";
2520
+ values.model ||= env.MOONSHOT_MODEL ?? process.env.MOONSHOT_MODEL ?? "";
2521
+ values.fallbackModel ||= env.MOONSHOT_FALLBACK_MODEL ?? process.env.MOONSHOT_FALLBACK_MODEL ?? "";
2522
+ }
2523
+ if (!values.baseUrl)
2524
+ values.baseUrl = defaultBaseUrlForLoginProvider(provider);
2525
+ if (!values.model)
2526
+ values.model = defaultModelForLoginProvider(provider);
2527
+ if (provider === "openai" && !values.endpoint)
2528
+ values.endpoint = "auto";
2529
+ return values;
2530
+ }
2531
+ function parseLoginProvider(value) {
2532
+ if (value === "openai" || value === "deepseek" || value === "kimi")
2533
+ return value;
2534
+ return undefined;
2535
+ }
2536
+ function guessLoginProvider(env) {
2537
+ if (env.KIMI_API_KEY ?? env.MOONSHOT_API_KEY ?? process.env.KIMI_API_KEY ?? process.env.MOONSHOT_API_KEY)
2538
+ return "kimi";
2539
+ if (env.DEEPSEEK_API_KEY ?? process.env.DEEPSEEK_API_KEY)
2540
+ return "deepseek";
2541
+ return "openai";
2542
+ }
2543
+ function defaultBaseUrlForLoginProvider(provider) {
2544
+ if (provider === "deepseek")
2545
+ return "https://api.deepseek.com";
2546
+ if (provider === "kimi")
2547
+ return "https://api.moonshot.cn/v1";
2548
+ return "https://api.openai.com";
2549
+ }
2550
+ function defaultModelForLoginProvider(provider) {
2551
+ if (provider === "deepseek")
2552
+ return "deepseek-chat";
2553
+ if (provider === "kimi")
2554
+ return "kimi-k2.6";
2555
+ return "gpt-5.5";
2556
+ }
2557
+ function loginFormViewHeight(state) {
2558
+ return state.step === "provider" ? state.providers.length + 3 : LOGIN_FIELD_DEFINITIONS[state.provider].length + 4;
2559
+ }
2560
+ function LoginFormView({ state, width }) {
2561
+ const contentWidth = Math.max(30, width);
2562
+ if (state.step === "provider") {
2563
+ 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)));
2564
+ }
2565
+ const fields = LOGIN_FIELD_DEFINITIONS[state.provider];
2566
+ const maxLabel = Math.max(...fields.map((field) => field.label.length));
2567
+ return e(Box, { flexDirection: "column", marginTop: 1 }, e(Text, { color: "cyan", bold: true }, fitToWidth(`Login: ${state.provider} · ${state.envPath}`, contentWidth)), ...fields.map((field, index) => {
2568
+ const selected = index === state.selectedFieldIndex;
2569
+ const rawValue = state.values[field.key] ?? "";
2570
+ const visibleValue = formatLoginFieldValue(field, rawValue, selected ? state.cursor : undefined);
2571
+ const placeholder = rawValue ? "" : (field.placeholder ? ` (${field.placeholder})` : "");
2572
+ 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))));
2573
+ }), 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)));
2574
+ }
2575
+ function formatLoginFieldValue(field, value, cursor) {
2576
+ const display = field.secret && value ? "•".repeat(Math.min(value.length, 24)) : value;
2577
+ if (cursor === undefined)
2578
+ return display;
2579
+ const safeCursor = Math.max(0, Math.min(cursor, display.length));
2580
+ const selected = display[safeCursor] ?? " ";
2581
+ return `${display.slice(0, safeCursor)}█${selected === " " ? "" : display.slice(safeCursor + 1)}`;
2582
+ }
2583
+ function applyLoginFormToProcessEnv(state) {
2584
+ applyEnvUpdatesToProcess(envEntriesForLoginForm(state));
2585
+ for (const key of DEPRECATED_MODEL_ENV_KEYS)
2586
+ delete process.env[key];
2587
+ }
2588
+ async function saveLoginFormToEnv(state) {
2589
+ await writeEnvUpdates(state.envPath, envEntriesForLoginForm(state), DEPRECATED_MODEL_ENV_KEYS);
2590
+ }
2591
+ function envEntriesForLoginForm(state) {
2592
+ const entries = {
2593
+ MODEL_PROVIDER: state.provider,
2594
+ };
2595
+ for (const field of LOGIN_FIELD_DEFINITIONS[state.provider]) {
2596
+ const value = (state.values[field.key] ?? "").trim();
2597
+ entries[field.envKey] = value || undefined;
2598
+ }
2599
+ if (state.provider === "kimi") {
2600
+ entries.MOONSHOT_API_KEY = undefined;
2601
+ entries.MOONSHOT_BASE_URL = undefined;
2602
+ entries.MOONSHOT_MODEL = undefined;
2603
+ entries.MOONSHOT_FALLBACK_MODEL = undefined;
2604
+ }
2605
+ return entries;
2606
+ }
2607
+ function updateEnvContent(content, updates, removeKeys = []) {
2608
+ const keys = new Set(Object.keys(updates));
2609
+ const removals = new Set(removeKeys);
2610
+ const seen = new Set();
2611
+ const lines = content ? content.split(/\r?\n/) : [];
2612
+ const updatedLines = lines.map((line) => {
2613
+ const parsed = parseEnvLine(line);
2614
+ if (!parsed)
2615
+ return line;
2616
+ if (removals.has(parsed.key) && !keys.has(parsed.key))
2617
+ return undefined;
2618
+ if (!keys.has(parsed.key))
2619
+ return line;
2620
+ seen.add(parsed.key);
2621
+ const value = updates[parsed.key];
2622
+ if (value === undefined)
2623
+ return undefined;
2624
+ return `${parsed.key}=${quoteEnvValue(value)}`;
2625
+ }).filter((line) => line !== undefined);
2626
+ const missing = Object.entries(updates).filter((entry) => !seen.has(entry[0]) && entry[1] !== undefined);
2627
+ if (missing.length > 0) {
2628
+ const grouped = groupLoginEnvEntries(missing);
2629
+ appendEnvGroup(updatedLines, "# Neo active provider", grouped.active);
2630
+ appendEnvGroup(updatedLines, "# OpenAI provider settings", grouped.openai);
2631
+ appendEnvGroup(updatedLines, "# DeepSeek provider settings", grouped.deepseek);
2632
+ appendEnvGroup(updatedLines, "# Kimi provider settings", grouped.kimi);
2633
+ appendEnvGroup(updatedLines, "# Shared model runtime settings", grouped.shared);
2634
+ }
2635
+ return `${updatedLines.join("\n").replace(/\n*$/u, "")}\n`;
2636
+ }
2637
+ function groupLoginEnvEntries(entries) {
2638
+ return {
2639
+ active: entries.filter(([key]) => key === "MODEL_PROVIDER"),
2640
+ openai: entries.filter(([key]) => key.startsWith("OPENAI_")),
2641
+ deepseek: entries.filter(([key]) => key.startsWith("DEEPSEEK_")),
2642
+ kimi: entries.filter(([key]) => key.startsWith("KIMI_") || key.startsWith("MOONSHOT_")),
2643
+ shared: entries.filter(([key]) => key.startsWith("MODEL_") && key !== "MODEL_PROVIDER"),
2644
+ };
2645
+ }
2646
+ function appendEnvGroup(lines, header, entries) {
2647
+ if (entries.length === 0)
2648
+ return;
2649
+ if (lines.length > 0 && lines[lines.length - 1]?.trim())
2650
+ lines.push("");
2651
+ lines.push(header);
2652
+ for (const [key, value] of entries)
2653
+ lines.push(`${key}=${quoteEnvValue(value)}`);
2654
+ }
2655
+ function parseEnvFileSafe(envPath) {
2656
+ if (!existsSync(envPath))
2657
+ return {};
2658
+ const env = {};
2659
+ for (const line of readFileSync(envPath, "utf8").split(/\r?\n/)) {
2660
+ const parsed = parseEnvLine(line);
2661
+ if (parsed)
2662
+ env[parsed.key] = stripEnvQuotes(parsed.value.trim());
2663
+ }
2664
+ return env;
2665
+ }
2666
+ function parseEnvLine(line) {
2667
+ const trimmed = line.trim();
2668
+ if (!trimmed || trimmed.startsWith("#"))
2669
+ return undefined;
2670
+ const separator = trimmed.indexOf("=");
2671
+ if (separator <= 0)
2672
+ return undefined;
2673
+ const key = trimmed.slice(0, separator).trim();
2674
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
2675
+ return undefined;
2676
+ return { key, value: trimmed.slice(separator + 1) };
2677
+ }
2678
+ function quoteEnvValue(value) {
2679
+ if (/^[A-Za-z0-9_./:@+-]*$/.test(value))
2680
+ return value;
2681
+ return JSON.stringify(value);
2682
+ }
2683
+ function stripEnvQuotes(value) {
2684
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))
2685
+ return value.slice(1, -1);
2686
+ return value;
2687
+ }
2051
2688
  function formatSessionBrowserRow(session, absoluteIndex, width) {
2052
2689
  const numberPrefix = `${absoluteIndex + 1}.`.padStart(4);
2053
2690
  const title = session.title?.trim() || "(untitled)";
@@ -2136,7 +2773,7 @@ function kindForRole(role) {
2136
2773
  }
2137
2774
  function titleForKind(kind) {
2138
2775
  if (kind === "thinking")
2139
- return `${THINKING_MARKER} Think`;
2776
+ return `${THINKING_MARKER} think`;
2140
2777
  if (kind === "tool")
2141
2778
  return "Tool";
2142
2779
  if (kind === "error")
@@ -2190,6 +2827,7 @@ function formatToolUse(toolUse) {
2190
2827
  return {
2191
2828
  kind: "tool",
2192
2829
  title: toolTitle(toolUse.name, "running"),
2830
+ bodyTitle: planToolBodyTitle(toolUse.input),
2193
2831
  text: formatPlanToolPayload(toolUse.input),
2194
2832
  };
2195
2833
  }
@@ -2205,6 +2843,7 @@ function formatToolResultLine(toolName, output, ok) {
2205
2843
  const line = {
2206
2844
  kind: ok ? "tool" : "error",
2207
2845
  title: toolTitle(toolName, "finished"),
2846
+ bodyTitle: formatted.bodyTitle,
2208
2847
  titleStatus: ok ? "success" : "failure",
2209
2848
  text: formatted.text,
2210
2849
  format: formatted.format,
@@ -2246,10 +2885,12 @@ function isPlanToolPayload(value) {
2246
2885
  (item.status === "pending" || item.status === "in_progress" || item.status === "completed"));
2247
2886
  });
2248
2887
  }
2888
+ function planToolBodyTitle(payload) {
2889
+ const title = payload.title?.trim();
2890
+ return title ? title : undefined;
2891
+ }
2249
2892
  function formatPlanToolPayload(payload) {
2250
2893
  const sections = [];
2251
- if (payload.title?.trim())
2252
- sections.push(`**${payload.title.trim()}**`);
2253
2894
  if (payload.summary?.trim())
2254
2895
  sections.push(payload.summary.trim());
2255
2896
  if (payload.note?.trim())
@@ -2374,7 +3015,7 @@ function formatToolResult(toolName, output, ok) {
2374
3015
  return { text: formatWebSearchToolResult(output, ok), summaryMaxLines: EXPANDED_SUMMARY_MAX_LINES };
2375
3016
  }
2376
3017
  if (toolName === "plan" && isPlanToolPayload(output)) {
2377
- return { text: formatPlanToolPayload(output), full: true };
3018
+ return { text: formatPlanToolPayload(output), bodyTitle: planToolBodyTitle(output), full: true };
2378
3019
  }
2379
3020
  return { text: `${ok ? "ok" : "failed"}\n${formatJson(output, 6000)}`, summaryMaxLines: EXPANDED_SUMMARY_MAX_LINES };
2380
3021
  }
@@ -2900,9 +3541,8 @@ function isFullWidthCodePoint(codePoint) {
2900
3541
  (codePoint >= 0x20000 && codePoint <= 0x3fffd)));
2901
3542
  }
2902
3543
  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;
3544
+ const TERMINAL_TITLE_WORKING_PREFIX = "● ";
3545
+ const TERMINAL_TITLE_READY_PREFIX = "";
2906
3546
  const REPL_ANIMATION_INTERVAL_MS = 420;
2907
3547
  const TOOL_RESULT_REPLACEMENT_DELAY_MS = 2000;
2908
3548
  const TOKEN_PULSE_MS = 900;