@zergai/ztc 0.2.6-dev.36101352252 → 0.2.7-dev.36236183737

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 (112) hide show
  1. package/README.md +43 -17
  2. package/bin/zmux-darwin-arm64 +0 -0
  3. package/bin/ztc-tui-darwin-arm64 +0 -0
  4. package/bin/zterm-darwin-arm64 +0 -0
  5. package/dist/App.js +19 -5
  6. package/dist/agent/agent.d.ts +5 -1
  7. package/dist/agent/agent.js +51 -8
  8. package/dist/agent/backends/account_pool.d.ts +5 -6
  9. package/dist/agent/backends/account_pool.js +39 -14
  10. package/dist/agent/backends/account_rotation.d.ts +3 -0
  11. package/dist/agent/backends/account_rotation.js +14 -3
  12. package/dist/agent/backends/codex_account_status.d.ts +42 -0
  13. package/dist/agent/backends/codex_account_status.js +196 -0
  14. package/dist/agent/backends/codex_app_server.d.ts +12 -2
  15. package/dist/agent/backends/codex_app_server.js +66 -6
  16. package/dist/agent/backends/index.d.ts +2 -0
  17. package/dist/agent/backends/index.js +13 -5
  18. package/dist/agent/backends/openai.d.ts +2 -2
  19. package/dist/agent/backends/openai.js +4 -3
  20. package/dist/agent/backends/openai_compatible.js +10 -10
  21. package/dist/agent/backends/openai_responses.d.ts +17 -5
  22. package/dist/agent/backends/openai_responses.js +2 -4
  23. package/dist/agent/backends/token_usage.js +1 -1
  24. package/dist/agent/backends/types.d.ts +3 -0
  25. package/dist/agent/commands/accounts.d.ts +7 -1
  26. package/dist/agent/commands/accounts.js +299 -101
  27. package/dist/agent/commands/config.js +11 -3
  28. package/dist/agent/commands/config_profile_switch.d.ts +2 -0
  29. package/dist/agent/commands/config_profile_switch.js +22 -0
  30. package/dist/agent/commands/context.js +4 -3
  31. package/dist/agent/commands/index.js +1 -2
  32. package/dist/agent/commands/input_mode.js +1 -1
  33. package/dist/agent/commands/model.js +7 -2
  34. package/dist/agent/commands/plan.d.ts +12 -0
  35. package/dist/agent/commands/plan.js +39 -28
  36. package/dist/agent/commands/profile.d.ts +1 -1
  37. package/dist/agent/commands/profile.js +34 -257
  38. package/dist/agent/commands/provider_choices.d.ts +12 -1
  39. package/dist/agent/commands/provider_choices.js +29 -2
  40. package/dist/agent/commands/provider_selection.d.ts +3 -0
  41. package/dist/agent/commands/provider_selection.js +63 -0
  42. package/dist/agent/commands/settings.d.ts +2 -0
  43. package/dist/agent/commands/settings.js +655 -50
  44. package/dist/agent/commands/types.d.ts +19 -2
  45. package/dist/agent/tools/ask_user.js +2 -2
  46. package/dist/app/agent_runner.d.ts +1 -1
  47. package/dist/app/agent_runner.js +2 -2
  48. package/dist/app/ask_user_response.d.ts +3 -0
  49. package/dist/app/ask_user_response.js +16 -0
  50. package/dist/app/command_context.js +29 -13
  51. package/dist/app/controller.d.ts +17 -2
  52. package/dist/app/controller.js +24 -1
  53. package/dist/app/selection_completion.d.ts +9 -3
  54. package/dist/app/selection_completion.js +54 -22
  55. package/dist/cli_args.js +10 -1
  56. package/dist/cli_main.js +85 -2
  57. package/dist/cli_ratatui.d.ts +22 -4
  58. package/dist/cli_ratatui.js +421 -146
  59. package/dist/config/defaults.d.ts +6 -0
  60. package/dist/config/defaults.js +20 -0
  61. package/dist/config/profiles.d.ts +9 -0
  62. package/dist/config/profiles.js +87 -0
  63. package/dist/config/types.d.ts +2 -0
  64. package/dist/config.d.ts +7 -0
  65. package/dist/config.js +283 -40
  66. package/dist/headless.js +14 -0
  67. package/dist/panes/model_catalog.d.ts +1 -1
  68. package/dist/panes/model_catalog.js +24 -2
  69. package/dist/panes/ztc_launch.js +6 -0
  70. package/dist/router_auth.js +3 -2
  71. package/dist/session.d.ts +2 -0
  72. package/dist/session.js +3 -0
  73. package/dist/types.d.ts +23 -2
  74. package/dist/ui/views/app.d.ts +3 -1
  75. package/dist/ui/views/app.js +3 -2
  76. package/dist/ui/views/completion_menu.js +6 -6
  77. package/dist/ui/views/input_area.d.ts +2 -1
  78. package/dist/ui/views/input_area.js +9 -8
  79. package/dist/ui/views/status_bar.d.ts +2 -0
  80. package/dist/ui/views/status_bar.js +15 -4
  81. package/dist/ui/widgets/color_panel.d.ts +9 -0
  82. package/dist/ui/widgets/color_panel.js +41 -26
  83. package/dist/ui/widgets/navigator_panel.d.ts +9 -0
  84. package/dist/ui/widgets/navigator_panel.js +17 -16
  85. package/dist/ui/widgets/panel_frame.d.ts +83 -3
  86. package/dist/ui/widgets/panel_frame.js +107 -9
  87. package/dist/ui/widgets/questionnaire.d.ts +9 -0
  88. package/dist/ui/widgets/questionnaire.js +214 -16
  89. package/dist/ui/widgets/resume_panel.d.ts +9 -0
  90. package/dist/ui/widgets/resume_panel.js +18 -17
  91. package/dist/ui/widgets/rewind_panel.d.ts +9 -0
  92. package/dist/ui/widgets/rewind_panel.js +15 -16
  93. package/dist/ui/widgets/select.d.ts +6 -0
  94. package/dist/ui/widgets/select.js +47 -12
  95. package/dist/ui/widgets/settings_panel.d.ts +75 -0
  96. package/dist/ui/widgets/settings_panel.js +197 -39
  97. package/dist/ui/widgets/status_line_panel.d.ts +9 -0
  98. package/dist/ui/widgets/status_line_panel.js +18 -19
  99. package/dist/ui/widgets/transcript_preview.d.ts +8 -0
  100. package/dist/ui/widgets/transcript_preview.js +10 -0
  101. package/dist/utils/context.js +2 -0
  102. package/dist/utils/fireworks.d.ts +20 -0
  103. package/dist/utils/fireworks.js +23 -0
  104. package/dist/utils/models.d.ts +2 -1
  105. package/dist/utils/models.js +19 -0
  106. package/dist/web/accounts_mirror.d.ts +7 -0
  107. package/dist/web/accounts_mirror.js +59 -7
  108. package/dist/web/mirror_stream.js +19 -5
  109. package/dist/zlm-embed.mjs +5734 -5362
  110. package/package.json +2 -2
  111. package/dist/agent/commands/provider.d.ts +0 -2
  112. package/dist/agent/commands/provider.js +0 -75
@@ -1,6 +1,7 @@
1
- import { getKimiModelInfo, getSupportedCodexReasoningEfforts, KIMI_CODE_MODEL_IDS, KIMI_MODEL_IDS, KIMI_MODEL_INFO, OFFICIAL_CODEX_MODEL_IDS, OFFICIAL_CODEX_MODEL_INFO, } from '../utils/models.js';
1
+ import { GPT_6_API_EFFORTS, getKimiModelInfo, getSupportedCodexReasoningEfforts, KIMI_CODE_MODEL_IDS, KIMI_MODEL_IDS, KIMI_MODEL_INFO, OFFICIAL_CODEX_MODEL_IDS, OFFICIAL_CODEX_MODEL_INFO, } from '../utils/models.js';
2
2
  import { DEEPSEEK_FLASH_EFFORTS, DEEPSEEK_FLASH_MODEL, MODAL_DEEPSEEK_FLASH_EFFORTS, MODAL_DEEPSEEK_FLASH_MODEL } from '../utils/deepseek.js';
3
- export const PANE_PROVIDERS = ['anthropic', 'claude_code', 'openai', 'codex_app_server', 'codex_legacy', 'gemini', 'deepseek', 'modal', 'kimi', 'kimi_code'];
3
+ import { FIREWORKS_CHAT_MODELS, FIREWORKS_FUNDED_MODEL } from '../utils/fireworks.js';
4
+ export const PANE_PROVIDERS = ['anthropic', 'claude_code', 'openai', 'codex_app_server', 'codex_legacy', 'gemini', 'deepseek', 'fireworks', 'modal', 'kimi', 'kimi_code'];
4
5
  export const MODEL_CATALOG = [
5
6
  {
6
7
  provider: 'anthropic',
@@ -25,6 +26,10 @@ export const MODEL_CATALOG = [
25
26
  provider: 'openai',
26
27
  label: 'OpenAI',
27
28
  models: [
29
+ { id: 'gpt-6-sol', name: 'GPT-6 Sol', aliases: ['gpt6sol', 'gpt-6 sol'],
30
+ defaultReasoningEffort: 'medium', supportedReasoningEfforts: GPT_6_API_EFFORTS },
31
+ { id: 'gpt-6-luna', name: 'GPT-6 Luna', aliases: ['gpt6luna', 'gpt-6 luna'],
32
+ defaultReasoningEffort: 'medium', supportedReasoningEfforts: GPT_6_API_EFFORTS },
28
33
  { id: 'gpt-5.4', name: 'GPT-5.4', aliases: ['gpt54', 'gpt5.4'] },
29
34
  { id: 'gpt-5.4-mini', name: 'GPT-5.4 Mini', aliases: ['gpt54mini', 'gpt-5.4 mini', 'gpt5.4mini'] },
30
35
  { id: 'gpt-5.4-nano', name: 'GPT-5.4 Nano', aliases: ['gpt54nano', 'gpt-5.4 nano', 'gpt5.4nano'] },
@@ -87,6 +92,19 @@ export const MODEL_CATALOG = [
87
92
  label: 'Kimi Code (subscription)',
88
93
  models: KIMI_CODE_MODEL_IDS.map(makeKimiCatalogModel),
89
94
  },
95
+ // Last on purpose: Fireworks reuses upstream slugs such as `kimi-k3`, and a
96
+ // bare id must resolve to the provider's own model before a Router mirror.
97
+ {
98
+ provider: 'fireworks',
99
+ label: 'Fireworks via Zerg Router',
100
+ models: FIREWORKS_CHAT_MODELS.map(model => ({
101
+ id: model.slug,
102
+ name: model.name,
103
+ description: model.slug === FIREWORKS_FUNDED_MODEL
104
+ ? 'Serverless through Zerg Router. Funded on platform credits unless your workspace holds a Fireworks key.'
105
+ : 'Serverless through Zerg Router. Needs a workspace Fireworks key.',
106
+ })),
107
+ },
90
108
  ];
91
109
  const PROVIDER_SET = new Set(PANE_PROVIDERS);
92
110
  const MODEL_BY_ID = new Map();
@@ -141,6 +159,10 @@ function makeKimiCatalogModel(id) {
141
159
  }
142
160
  function getCodexModelAliases(id) {
143
161
  switch (id) {
162
+ case 'gpt-6-sol':
163
+ return ['gpt6sol', 'gpt-6 sol'];
164
+ case 'gpt-6-luna':
165
+ return ['gpt6luna', 'gpt-6 luna'];
144
166
  case 'gpt-5.6-sol':
145
167
  return ['gpt-5.6', 'gpt56', 'gpt5.6', 'gpt56sol', 'sol'];
146
168
  case 'gpt-5.6-terra':
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from 'fs';
2
2
  import { dirname, join } from 'path';
3
3
  import { generateSessionId } from '../session.js';
4
+ import { activeProfile } from '../config/profiles.js';
4
5
  const CHILD_ZTC_SCRIPT_RE = /(?:^|[\\/])cli_(?:main|ratatui)\.(?:js|ts)$/;
5
6
  function filterChildZtcArgs(args) {
6
7
  const skipFlags = new Set([
@@ -28,6 +29,7 @@ function filterChildZtcArgs(args) {
28
29
  '--session-id',
29
30
  '--provider',
30
31
  '--model',
32
+ '--profile',
31
33
  '--cwd',
32
34
  '--mode',
33
35
  '--prompt',
@@ -37,6 +39,8 @@ function filterChildZtcArgs(args) {
37
39
  const filtered = [];
38
40
  for (let i = 0; i < args.length; i++) {
39
41
  const arg = args[i];
42
+ if (arg === '--')
43
+ break;
40
44
  if (i === 0 && arg === 'resume') {
41
45
  if (args[i + 1] && !args[i + 1].startsWith('-')) {
42
46
  i++;
@@ -64,6 +68,8 @@ export function buildZtcPaneLaunchCommand(spec, argv = process.argv) {
64
68
  }
65
69
  const parentArgs = filterChildZtcArgs(argv.slice(2));
66
70
  const childArgs = [...parentArgs, ...(spec.extraArgs || [])];
71
+ if (!childArgs.includes('--profile'))
72
+ childArgs.push('--profile', activeProfile());
67
73
  if (spec.session.mode === 'resume') {
68
74
  childArgs.push('--resume', spec.session.id);
69
75
  }
@@ -1,17 +1,18 @@
1
1
  import { createHash, randomBytes, randomUUID } from 'node:crypto';
2
2
  import { chmod, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
3
- import { homedir, hostname } from 'node:os';
3
+ import { hostname } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { spawn } from 'node:child_process';
6
6
  import { setTimeout as delay } from 'node:timers/promises';
7
7
  import { getVersion } from './utils/version.js';
8
+ import { profileConfigDir } from './config/profiles.js';
8
9
  export const DEFAULT_ROUTER_ORIGIN = 'https://zergrouter.com';
9
10
  /** Local, actionable routing failures whose messages contain no remote payload. */
10
11
  export class RouterConfigurationError extends Error {
11
12
  }
12
13
  const terminalOutput = (message, stream) => { process[stream].write(message); };
13
14
  function credentialDirectory() {
14
- return process.env.ZTC_CONFIG_DIR || join(homedir(), '.ztc');
15
+ return profileConfigDir();
15
16
  }
16
17
  function credentialPath() {
17
18
  return join(credentialDirectory(), 'router-auth.json');
package/dist/session.d.ts CHANGED
@@ -24,6 +24,8 @@ export interface SessionMetadata {
24
24
  cwd: string;
25
25
  model: string;
26
26
  provider: string;
27
+ /** Config profile selected for this session; absent in sessions saved before profiles. */
28
+ configProfile?: string;
27
29
  createdAt: string;
28
30
  updatedAt: string;
29
31
  messageCount: number;
package/dist/session.js CHANGED
@@ -19,6 +19,7 @@ import { homedir } from 'os';
19
19
  import { createHash } from 'crypto';
20
20
  import { writeFileAtomic } from './utils/atomic_write.js';
21
21
  import { stripEphemeralMessages } from './utils/ephemeral.js';
22
+ import { activeProfile } from './config/profiles.js';
22
23
  /**
23
24
  * Schema version for on-disk Session payloads.
24
25
  *
@@ -681,6 +682,7 @@ export async function saveSession(sessionId, cwd, messages, model, provider, mux
681
682
  cwd,
682
683
  model,
683
684
  provider,
685
+ configProfile: activeProfile(),
684
686
  createdAt: existingSession?.metadata.createdAt || new Date().toISOString(),
685
687
  updatedAt: new Date().toISOString(),
686
688
  messageCount: messages.length,
@@ -775,6 +777,7 @@ export async function saveSessionToFile(path, sessionId, cwd, messages, model, p
775
777
  cwd,
776
778
  model,
777
779
  provider,
780
+ configProfile: activeProfile(),
778
781
  createdAt,
779
782
  updatedAt: new Date().toISOString(),
780
783
  messageCount: messages.length,
package/dist/types.d.ts CHANGED
@@ -12,12 +12,29 @@ export type TodoStatus = typeof TODO_STATUSES[number];
12
12
  */
13
13
  export declare const AGENT_STATUS_LABELS: Record<AgentStatus, string>;
14
14
  export type InputMode = 'queue' | 'interrupt';
15
+ /**
16
+ * When a busy-turn submission should reach the model.
17
+ *
18
+ * - `subturn`: insert after the current tool call finishes, inside the running
19
+ * turn (Enter). Falls back to a fresh turn when no tool boundary remains.
20
+ * - `end-of-turn`: hold until the agent finishes the whole turn (Tab).
21
+ *
22
+ * This is the message's own intent, independent of `InputMode`. `InputMode`
23
+ * only selects what *Enter* does while busy; it must never strand a message the
24
+ * user queued with Tab.
25
+ */
26
+ export type DeliveryIntent = 'subturn' | 'end-of-turn';
15
27
  /** A single entry in the outbound message queue. Plain data — JSON-safe. */
16
28
  export interface QueueEntry {
17
29
  id: string;
18
30
  text: string;
19
31
  attachments?: MessageAttachment[];
20
32
  queuedAt: Date;
33
+ /**
34
+ * Delivery timing for this entry. Absent on legacy persisted entries means
35
+ * `end-of-turn` (the conservative "wait for the whole turn" behavior).
36
+ */
37
+ delivery?: DeliveryIntent;
21
38
  }
22
39
  export interface ToolCall {
23
40
  id: string;
@@ -86,9 +103,11 @@ export interface AgentState {
86
103
  * undefined falls back to MODEL_CONTEXT_WINDOWS.
87
104
  */
88
105
  contextWindow?: number;
106
+ /** Raw input tokens for the last model call, before durable-context adjustments. */
107
+ cacheInputTokens?: number;
89
108
  /**
90
- * Provider-reported cached input for the last turn, as a subset of
91
- * `contextTokens`. Absent means the provider reported no cache accounting —
109
+ * Provider-reported cached input for the last model call, as a subset of
110
+ * `cacheInputTokens`. Absent means the provider reported no cache accounting —
92
111
  * NOT a 0% hit rate. See `cacheHitRate` in utils/context.ts.
93
112
  */
94
113
  cachedInputTokens?: number;
@@ -167,6 +186,8 @@ export type AgentEvent = {
167
186
  usage: TokenUsage;
168
187
  contextTokens?: number;
169
188
  contextWindow?: number;
189
+ cacheInputTokens?: number;
190
+ contextCachedInputTokens?: number;
170
191
  } | {
171
192
  type: 'api_retry';
172
193
  attempt: number;
@@ -65,6 +65,8 @@ interface AppViewProps {
65
65
  statusLine?: StatusLineSettings;
66
66
  /** Effort in effect for the active model; undefined follows the provider. */
67
67
  effort?: CodexReasoningEffort;
68
+ /** Saved effort the active model rejects; the status line flags it. */
69
+ unsupportedEffort?: CodexReasoningEffort;
68
70
  /**
69
71
  * When true, the message-list region is painted by the mux pane via
70
72
  * scrollback (Phase 1+ of always-scrolling rollout) and the Ink
@@ -104,5 +106,5 @@ export declare function getAppLayoutMetrics({ rows, cols, inputState, commands,
104
106
  widgetHeight: number;
105
107
  transcriptHeight: number;
106
108
  };
107
- export declare function buildAppView({ messages, agentState, inputState, sessionId, sessionName, cols, rows, commands, completion, hasApiKey, version, contextLength, contextEstimated, contextStatus, provider, model, emulationId, inputMode, toast, spinnerLabel, spinnerFrame, lastEventAt, stallHintMs, debug, expandToolOutputs, showThinking, renderer, showHeader, showMessages, isRecording, recordingDevice, audioLevel, partialTranscript, todos, showTodos, activeTask, taskStartedAt, mode, authEmail, authWorkspace, backgroundRunning, widgetContent, widgetView: providedWidgetView, queuePreview, queueTotal, queueSelectedIndex, externalTranscript, compactWidget, agentActive, statusLine, effort, }: AppViewProps): LayoutNode;
109
+ export declare function buildAppView({ messages, agentState, inputState, sessionId, sessionName, cols, rows, commands, completion, hasApiKey, version, contextLength, contextEstimated, contextStatus, provider, model, emulationId, inputMode, toast, spinnerLabel, spinnerFrame, lastEventAt, stallHintMs, debug, expandToolOutputs, showThinking, renderer, showHeader, showMessages, isRecording, recordingDevice, audioLevel, partialTranscript, todos, showTodos, activeTask, taskStartedAt, mode, authEmail, authWorkspace, backgroundRunning, widgetContent, widgetView: providedWidgetView, queuePreview, queueTotal, queueSelectedIndex, externalTranscript, compactWidget, agentActive, statusLine, effort, unsupportedEffort, }: AppViewProps): LayoutNode;
108
110
  export {};
@@ -93,7 +93,7 @@ export function getAppLayoutMetrics({ rows, cols, inputState, commands = [], com
93
93
  // the menu instead of reserving a partial row or displacing the status line.
94
94
  const completionHeight = activeCompletion?.kind === 'usage'
95
95
  ? Math.min(1, completionBudget)
96
- : completionBudget >= 2 ? Math.min(selectionCompletionRows(activeCompletion), completionBudget) : 0;
96
+ : completionBudget >= 2 ? selectionCompletionRows(activeCompletion, completionBudget) : 0;
97
97
  const inputHeight = Math.min(inputLineCount + previewLines, maxInputLines) + completionHeight + inputOverhead;
98
98
  const auxiliaryBudget = Math.max(0, rows - headerHeight - statusHeight - activityHeight - inputHeight - transcriptReserve);
99
99
  const { queueHeight, todoHeight } = splitAuxiliaryBudget(auxiliaryBudget, rawQueueHeight, rawTodoHeight);
@@ -148,7 +148,7 @@ export function getAppLayoutMetrics({ rows, cols, inputState, commands = [], com
148
148
  }
149
149
  export function buildAppView({ messages, agentState, inputState, sessionId, sessionName, cols, rows, commands, completion, hasApiKey, version = '0.1.0', contextLength, contextEstimated, contextStatus, provider, model, emulationId, inputMode, toast, spinnerLabel, spinnerFrame, lastEventAt, stallHintMs, debug = false, expandToolOutputs = false, showThinking = true, renderer, showHeader = true, showMessages = true, isRecording = false, recordingDevice, audioLevel = -60, partialTranscript = '', todos = [], showTodos = true, activeTask = null, taskStartedAt = null, mode = 'normal', authEmail, authWorkspace, backgroundRunning = 0, widgetContent = null, widgetView: providedWidgetView = null, queuePreview = [], queueTotal = 0, queueSelectedIndex = null, externalTranscript = false, compactWidget = false, agentActive = agentState.status === 'thinking'
150
150
  || agentState.status === 'streaming'
151
- || agentState.status === 'tool_use', statusLine, effort, }) {
151
+ || agentState.status === 'tool_use', statusLine, effort, unsupportedEffort, }) {
152
152
  // A compact widget (the question form) sizes to its own content. Deriving the
153
153
  // row count here keeps the widget region and the transcript reserve aligned
154
154
  // with what the frame actually paints.
@@ -269,6 +269,7 @@ export function buildAppView({ messages, agentState, inputState, sessionId, sess
269
269
  model,
270
270
  emulationId,
271
271
  effort,
272
+ unsupportedEffort,
272
273
  inputMode,
273
274
  toast,
274
275
  debug,
@@ -42,10 +42,10 @@ export function buildCompletionMenu(view, cols) {
42
42
  if (view.kind === 'usage') {
43
43
  return { menu: box([text(clip(`Usage: ${view.usage ?? ''}`, width), muted)], { paddingX: 2, height: 1 }) };
44
44
  }
45
- const rowCount = Math.max(1, Math.min(SELECTION_VISIBLE_ROWS, view.visibleRows ?? SELECTION_VISIBLE_ROWS));
46
- const first = Math.max(0, view.selected - rowCount + 1, Math.min(view.firstVisible ?? 0, view.selected));
45
+ const rowCount = Math.max(1, view.visibleRows ?? SELECTION_VISIBLE_ROWS);
46
+ const first = Math.min(Math.max(0, view.candidates.length - rowCount), Math.max(0, view.selected - rowCount + 1, Math.min(view.firstVisible ?? 0, view.selected)));
47
47
  const kind = view.kind ?? (view.provider ? 'model' : 'provider');
48
- const title = kind === 'model' ? `Models · ${view.provider ?? ''}` : kind === 'command' ? 'Commands' : 'Providers';
48
+ const title = kind === 'model' ? `Models · ${view.provider ?? ''}` : kind === 'command' ? 'Commands' : view.mixed ? 'Providers & models' : 'Providers';
49
49
  const count = view.candidates.length ? `${view.selected + 1}/${view.candidates.length}` : '0 results';
50
50
  const state = view.loading ? ' · loading' : view.unavailable ? ' · cached' : '';
51
51
  const heading = clip(title + state, Math.max(1, width - displayWidth(count) - 2));
@@ -84,10 +84,10 @@ export function buildCompletionMenu(view, cols) {
84
84
  rows.push(text(clip(empty, width), muted));
85
85
  }
86
86
  const selected = view.candidates[view.selected];
87
- const action = kind === 'command' ? selected?.needsArgument ? 'open' : 'run' : 'apply';
87
+ const enterAction = selected && view.query === selected.value && !selected.needsArgument ? 'run' : 'fill';
88
88
  const hints = view.candidates.length ? [
89
- `↑↓ move · Tab fill · Enter ${action} · Esc close`,
90
- `↑↓ · Tab fill · ↵ ${action} · Esc`,
89
+ enterAction === 'run' ? '↑↓ move · Tab fill · Enter run · Esc close' : '↑↓ move · Tab/Enter fill · Esc close',
90
+ enterAction === 'run' ? '↑↓ · Tab fill · ↵ run · Esc' : '↑↓ · Tab/↵ fill · Esc',
91
91
  '↑↓ Tab ↵ Esc',
92
92
  ] : [kind === 'command' ? 'Keep typing · Esc close' : 'Enter exact ID · Esc close', '↵ exact ID · Esc', '↵ Esc'];
93
93
  const hint = hints.find(value => displayWidth(value) <= width) ?? hints.at(-1);
@@ -18,7 +18,7 @@ interface InputAreaViewProps {
18
18
  spinnerFrame?: string | null;
19
19
  inputMode?: string;
20
20
  }
21
- interface RenderToken {
21
+ export interface RenderToken {
22
22
  text: string;
23
23
  style?: {
24
24
  color?: string;
@@ -42,6 +42,7 @@ export declare function wrapInputSegments(segments: InputSegment[], cursor: {
42
42
  cursorLine: number;
43
43
  cursorCol: number;
44
44
  };
45
+ export declare function renderLineWithCursorTokens(line: RenderToken[], cursorCol: number, isCursorLine: boolean): RenderToken[];
45
46
  export declare function estimateInputLines(segments: InputSegment[], cols: number): number;
46
47
  export declare function buildInputAreaView({ state, placeholder, disabled, commands, completion, cols, maxInputLines, badgePreview, showBadgePreview, debug, renderContent, spinnerLabel, spinnerFrame }: InputAreaViewProps): LayoutNode;
47
48
  export {};
@@ -145,9 +145,9 @@ export function wrapInputSegments(segments, cursor, cols, prefixWidth = promptWi
145
145
  }
146
146
  return { lines, cursorLine, cursorCol };
147
147
  }
148
- function renderLineWithCursor(line, cursorCol, isCursorLine) {
148
+ export function renderLineWithCursorTokens(line, cursorCol, isCursorLine) {
149
149
  if (!isCursorLine)
150
- return line.map(token => text(token.text, token.style));
150
+ return line;
151
151
  const nodes = [];
152
152
  let col = 0;
153
153
  let cursorRendered = false;
@@ -165,17 +165,17 @@ function renderLineWithCursor(line, cursorCol, isCursorLine) {
165
165
  }
166
166
  if (cursorOffset >= 0) {
167
167
  if (cursorOffset)
168
- nodes.push(text(token.text.slice(0, cursorOffset), token.style));
169
- nodes.push(text(token.text.slice(cursorOffset, cursorEnd), { ...token.style, inverse: true }));
168
+ nodes.push({ text: token.text.slice(0, cursorOffset), style: token.style });
169
+ nodes.push({ text: token.text.slice(cursorOffset, cursorEnd), style: { ...token.style, inverse: true } });
170
170
  if (cursorEnd < token.text.length)
171
- nodes.push(text(token.text.slice(cursorEnd), token.style));
171
+ nodes.push({ text: token.text.slice(cursorEnd), style: token.style });
172
172
  }
173
173
  else {
174
- nodes.push(text(token.text, token.style));
174
+ nodes.push(token);
175
175
  }
176
176
  }
177
177
  if (!cursorRendered && cursorCol === col)
178
- nodes.push(text(' ', { inverse: true }));
178
+ nodes.push({ text: ' ', style: { inverse: true } });
179
179
  return nodes;
180
180
  }
181
181
  export function estimateInputLines(segments, cols) {
@@ -222,7 +222,8 @@ export function buildInputAreaView({ state, placeholder, disabled = false, comma
222
222
  : sourceLine === lastLine - 1 && lastLine < wrapped.lines.length ? '↧ ' : ' ';
223
223
  const segments = [
224
224
  text(prefix, { color: promptColor, bold: index === 0 }),
225
- ...renderLineWithCursor(line, wrapped.cursorCol, sourceLine === wrapped.cursorLine),
225
+ ...renderLineWithCursorTokens(line, wrapped.cursorCol, sourceLine === wrapped.cursorLine)
226
+ .map(token => text(token.text, token.style)),
226
227
  ];
227
228
  return box(segments, { flexDirection: 'row' });
228
229
  });
@@ -18,6 +18,8 @@ interface StatusBarProps {
18
18
  emulationId?: string;
19
19
  /** Effort in effect for the active model; undefined follows the provider. */
20
20
  effort?: CodexReasoningEffort;
21
+ /** Saved effort the active model rejects, shown so it is not silently ignored. */
22
+ unsupportedEffort?: CodexReasoningEffort;
21
23
  inputMode?: 'queue' | 'interrupt';
22
24
  toast?: string | null;
23
25
  spinnerLabel?: string | null;
@@ -104,7 +104,7 @@ function statusItemForms(item, props) {
104
104
  // Undefined when the provider reports no cache accounting. The field is then
105
105
  // omitted rather than painted as 0%, so an unknown rate is never read as a
106
106
  // measured miss.
107
- const cacheShare = cacheHitRate(props.state.cachedInputTokens, usedTokens);
107
+ const cacheShare = cacheHitRate(props.state.cachedInputTokens, props.state.cacheInputTokens ?? usedTokens);
108
108
  const cacheLabel = cacheShare === undefined ? undefined : `${Math.round(cacheShare)}%`;
109
109
  const percentage = props.contextStatus ? `${Math.round(props.contextStatus.percentage)}%` : undefined;
110
110
  const remaining = props.contextStatus ? `${Math.max(0, Math.round(props.contextStatus.remaining))}%` : undefined;
@@ -172,8 +172,19 @@ function statusItemForms(item, props) {
172
172
  short: emulation && `emu:${emulation}`,
173
173
  long: emulation && `Emulating ${emulation}`,
174
174
  };
175
- case 'effort':
176
- return { raw: props.effort, short: props.effort && `eff ${props.effort}`, long: props.effort && `Effort ${props.effort}` };
175
+ case 'effort': {
176
+ const unsupported = props.unsupportedEffort;
177
+ if (!unsupported) {
178
+ return { raw: props.effort, short: props.effort && `eff ${props.effort}`, long: props.effort && `Effort ${props.effort}` };
179
+ }
180
+ return {
181
+ raw: unsupported,
182
+ short: props.effort ? `eff ${props.effort} (${unsupported} n/a)` : `eff ${unsupported} n/a`,
183
+ long: props.effort
184
+ ? `Effort ${props.effort} (${unsupported} unsupported by this model)`
185
+ : `Effort ${unsupported} unsupported by this model`,
186
+ };
187
+ }
177
188
  case 'reasoning':
178
189
  // The field is the thinking switch, so the short form says the thing the
179
190
  // user is actually turning on rather than restating the flag.
@@ -212,7 +223,7 @@ function statusItemColor(item, props) {
212
223
  case 'model':
213
224
  case 'provider-model': return TERMINAL_THEME.muted;
214
225
  case 'emulation': return TERMINAL_THEME.info;
215
- case 'effort': return TERMINAL_THEME.muted;
226
+ case 'effort': return props.unsupportedEffort ? TERMINAL_THEME.attention : TERMINAL_THEME.muted;
216
227
  case 'reasoning': return reasoningEnabled(props.effort) ? TERMINAL_THEME.info : TERMINAL_THEME.muted;
217
228
  case 'input-mode': return TERMINAL_THEME.attention;
218
229
  case 'version': return TERMINAL_THEME.subtle;
@@ -46,6 +46,15 @@ export interface ColorPanelSurface {
46
46
  export declare class ColorPanelWidget {
47
47
  private readonly host;
48
48
  private active;
49
+ /**
50
+ * Column the painted frame starts on, in surface coordinates.
51
+ *
52
+ * Zero unless the terminal is wide enough to trigger the shared cap, in which
53
+ * case it is that centring margin. The wheel bands add it back: a notch lands
54
+ * in terminal columns, so without it a capped page would scroll from a pane
55
+ * that is no longer under the pointer.
56
+ */
57
+ private frameInset;
49
58
  private draft;
50
59
  private focus;
51
60
  private presetIndex;
@@ -25,7 +25,7 @@ import { displayWidth, padEndDisplay } from '../core/text_width.js';
25
25
  import { TERMINAL_THEME } from '../views/theme.js';
26
26
  import { COLOR_PRESETS, blendOver, formatColor, parseColor } from '../../config/colors.js';
27
27
  import { fuzzyMatch } from '../../utils/fuzzy_match.js';
28
- import { bodyBand, bottomBorder, clipText, dropLastGrapheme, framedLine, isInsideBand, lineText, planPanelSplit, sideBySideBands, stackedBands, topBorder, WHEEL_LINES, } from './panel_frame.js';
28
+ import { widgetFrameGeometry, bodyBand, bottomBorder, clipText, dropLastGrapheme, framedLine, isInsideBand, lineText, planPanelSplit, sideBySideBands, stackedBands, topBorder, WHEEL_LINES, } from './panel_frame.js';
29
29
  const CHANNEL_ORDER = ['hex', 'r', 'g', 'b', 'a'];
30
30
  const CHANNEL_LABELS = {
31
31
  hex: 'Hex',
@@ -76,6 +76,15 @@ function presetsFor(data) {
76
76
  export class ColorPanelWidget {
77
77
  host;
78
78
  active = null;
79
+ /**
80
+ * Column the painted frame starts on, in surface coordinates.
81
+ *
82
+ * Zero unless the terminal is wide enough to trigger the shared cap, in which
83
+ * case it is that centring margin. The wheel bands add it back: a notch lands
84
+ * in terminal columns, so without it a capped page would scroll from a pane
85
+ * that is no longer under the pointer.
86
+ */
87
+ frameInset = 0;
79
88
  draft = { r: 255, g: 255, b: 255, a: 1 };
80
89
  focus = 'presets';
81
90
  presetIndex = 0;
@@ -324,28 +333,33 @@ export class ColorPanelWidget {
324
333
  // ── Rendering ─────────────────────────────────────────────────────────────
325
334
  buildSurface(cols, maxRows) {
326
335
  const lines = this.buildSurfaceLines(cols, maxRows);
327
- return {
328
- content: lines.map(lineText).join('\n'),
329
- view: box(lines.map(line => box(line.segments.map(segment => (segment.backgroundColor
330
- // A swatch needs a filled cell, which is a background on the run and
331
- // not a colour on the glyph.
332
- ? box([text(segment.text, segment.style)], {
333
- flexDirection: 'row',
334
- height: 1,
335
- flexShrink: 0,
336
- backgroundColor: segment.backgroundColor,
337
- })
338
- : text(segment.text, segment.style))), {
336
+ // This panel keeps its own view builder rather than the shared one: a swatch
337
+ // needs a filled cell, which is a background on the run and not a colour on
338
+ // the glyph. The centring margin still goes on the frame, so it lands on the
339
+ // same column as every other page.
340
+ const frame = box(lines.map(line => box(line.segments.map(segment => (segment.backgroundColor
341
+ ? box([text(segment.text, segment.style)], {
339
342
  flexDirection: 'row',
340
343
  height: 1,
341
344
  flexShrink: 0,
342
- backgroundColor: line.backgroundColor,
343
- })), {
344
- flexDirection: 'column',
345
- height: lines.length,
346
- flexShrink: 0,
347
- backgroundColor: TERMINAL_THEME.bg,
348
- }),
345
+ backgroundColor: segment.backgroundColor,
346
+ })
347
+ : text(segment.text, segment.style))), {
348
+ flexDirection: 'row',
349
+ height: 1,
350
+ flexShrink: 0,
351
+ backgroundColor: line.backgroundColor,
352
+ })), {
353
+ flexDirection: 'column',
354
+ height: lines.length,
355
+ flexShrink: 0,
356
+ backgroundColor: TERMINAL_THEME.bg,
357
+ });
358
+ return {
359
+ content: lines.map(lineText).join('\n'),
360
+ view: this.frameInset > 0
361
+ ? box([frame], { flexDirection: 'column', marginLeft: this.frameInset, flexShrink: 0 })
362
+ : frame,
349
363
  lineCount: lines.length,
350
364
  };
351
365
  }
@@ -358,9 +372,10 @@ export class ColorPanelWidget {
358
372
  if (maxRows !== undefined && maxRows <= 0)
359
373
  return [];
360
374
  const termCols = Math.max(1, Math.floor(cols || process.stdout.columns || 80));
361
- // The host embeds the surface in a `paddingX: 1` column, so `cols - 2` is
362
- // the full usable width.
363
- const width = Math.max(1, termCols - 2);
375
+ // The host embeds the surface in a `paddingX: 1` column, so the frame asks
376
+ // for `termCols - 2`; the shared cap bounds the frame and it sits flush left.
377
+ const { width, inset } = widgetFrameGeometry(termCols);
378
+ this.frameInset = inset;
364
379
  const height = Math.max(1, Math.floor(maxRows ?? 20));
365
380
  const innerWidth = Math.max(0, width - 4);
366
381
  const lines = [topBorder(width, this.active.label, this.currentValue())];
@@ -415,7 +430,7 @@ export class ColorPanelWidget {
415
430
  ...padRow(channels.rows[index], split.previewWidth),
416
431
  ], width));
417
432
  }
418
- this.presetsBand = sideBySideBands(headingRow, lines.length - 1, split.listWidth, split.previewWidth).list;
433
+ this.presetsBand = sideBySideBands(headingRow, lines.length - 1, split.listWidth, split.previewWidth, this.frameInset).list;
419
434
  }
420
435
  else if (placement === 'bottom') {
421
436
  const channelRows = this.channelRows(innerWidth);
@@ -436,7 +451,7 @@ export class ColorPanelWidget {
436
451
  for (let index = 0; index < channelCapacity; index += 1) {
437
452
  lines.push(framedLine(channels.rows[index]?.segments ?? [], width));
438
453
  }
439
- this.presetsBand = stackedBands({ top: presetsTop - presetCapacity + 1, bottom: presetsTop }, { top: channelsTop, bottom: lines.length - 1 }, innerWidth).list;
454
+ this.presetsBand = stackedBands({ top: presetsTop - presetCapacity + 1, bottom: presetsTop }, { top: channelsTop, bottom: lines.length - 1 }, innerWidth, this.frameInset).list;
440
455
  }
441
456
  else {
442
457
  // Too short for two panes: the one being worked in takes the whole body.
@@ -446,7 +461,7 @@ export class ColorPanelWidget {
446
461
  for (let index = 0; index < bodyRows; index += 1) {
447
462
  lines.push(framedLine(windowed.rows[index]?.segments ?? [], width));
448
463
  }
449
- this.presetsBand = bodyBand(top, lines.length - 1, innerWidth);
464
+ this.presetsBand = bodyBand(top, lines.length - 1, innerWidth, this.frameInset);
450
465
  }
451
466
  // Each preview row is framed like every other line, so the bar is painted
452
467
  // inside the page rather than across it.
@@ -88,6 +88,15 @@ export declare class NavigatorPanelWidget {
88
88
  private refreshInFlight;
89
89
  private lastBodyRows;
90
90
  private lastInnerWidth;
91
+ /**
92
+ * Column the painted frame starts on, in surface coordinates.
93
+ *
94
+ * Zero unless the terminal is wide enough to trigger the shared cap, in which
95
+ * case it is that centring margin. The wheel bands add it back: a notch lands
96
+ * in terminal columns, so without it a capped page would scroll from a pane
97
+ * that is no longer under the pointer.
98
+ */
99
+ private frameInset;
91
100
  private lastSplit;
92
101
  private visibleCache;
93
102
  private focus;
@@ -28,11 +28,10 @@
28
28
  * Secondary keys and the filter share one keyboard, as in the other pickers:
29
29
  * letters are actions until `/` (or any unused letter) opens the query.
30
30
  */
31
- import { box, text } from '../core/index.js';
32
31
  import { displayWidth, padEndDisplay } from '../core/text_width.js';
33
32
  import { TERMINAL_THEME } from '../views/theme.js';
34
33
  import { fuzzyMatch } from '../../utils/fuzzy_match.js';
35
- import { bottomBorder, clipText, dropLastGrapheme, fitSegments, framedLine, isInsideBand, joinColumns, lineText, planPanelSplit, sideBySideBands, stackedBands, topBorder, WHEEL_LINES, } from './panel_frame.js';
34
+ import { bottomBorder, clipText, dropLastGrapheme, fitSegments, framedLine, isInsideBand, joinColumns, lineText, planPanelSplit, sideBySideBands, stackedBands, topBorder, WHEEL_LINES, buildWidgetFrameView, widgetFrameGeometry, } from './panel_frame.js';
36
35
  import { buildTranscriptLines, transcriptHeaderText, windowedLines, TRANSCRIPT_PANE_MIN_BODY_ROWS, TRANSCRIPT_PANE_MIN_WIDTH, } from './transcript_preview.js';
37
36
  /** Chrome rows: top border, filter line, footer, bottom border, one slack. */
38
37
  const CHROME_ROWS = 5;
@@ -51,6 +50,15 @@ export class NavigatorPanelWidget {
51
50
  refreshInFlight = false;
52
51
  lastBodyRows = 1;
53
52
  lastInnerWidth = 78;
53
+ /**
54
+ * Column the painted frame starts on, in surface coordinates.
55
+ *
56
+ * Zero unless the terminal is wide enough to trigger the shared cap, in which
57
+ * case it is that centring margin. The wheel bands add it back: a notch lands
58
+ * in terminal columns, so without it a capped page would scroll from a pane
59
+ * that is no longer under the pointer.
60
+ */
61
+ frameInset = 0;
54
62
  lastSplit = null;
55
63
  visibleCache = null;
56
64
  focus = 'list';
@@ -559,17 +567,7 @@ export class NavigatorPanelWidget {
559
567
  const lines = this.buildSurfaceLines(cols, maxRows);
560
568
  return {
561
569
  content: lines.map(lineText).join('\n'),
562
- view: box(lines.map(line => box(line.segments.map(segment => text(segment.text, segment.style)), {
563
- flexDirection: 'row',
564
- height: 1,
565
- flexShrink: 0,
566
- backgroundColor: line.backgroundColor,
567
- })), {
568
- flexDirection: 'column',
569
- height: Math.max(1, lines.length),
570
- flexShrink: 0,
571
- backgroundColor: TERMINAL_THEME.bg,
572
- }),
570
+ view: buildWidgetFrameView(lines, this.frameInset),
573
571
  lineCount: lines.length,
574
572
  };
575
573
  }
@@ -602,7 +600,8 @@ export class NavigatorPanelWidget {
602
600
  if (maxRows !== undefined && maxRows <= 0)
603
601
  return [];
604
602
  const termCols = Math.max(1, Math.floor(cols || process.stdout.columns || 80));
605
- const width = Math.max(1, termCols - 2);
603
+ const { width, inset } = widgetFrameGeometry(termCols);
604
+ this.frameInset = inset;
606
605
  const height = Math.max(1, Math.floor(maxRows ?? 20));
607
606
  const innerWidth = Math.max(0, width - 4);
608
607
  this.lastInnerWidth = innerWidth;
@@ -691,7 +690,7 @@ export class NavigatorPanelWidget {
691
690
  ...(previewRow ? previewRow.segments : []),
692
691
  ], width));
693
692
  }
694
- this.paneBands = sideBySideBands(bandTop, lines.length - 1, split.listWidth, paneWidth);
693
+ this.paneBands = sideBySideBands(bandTop, lines.length - 1, split.listWidth, paneWidth, this.frameInset);
695
694
  }
696
695
  /** Transcript under the list, with its own heading — a narrow terminal. */
697
696
  drawStackedPane(lines, split, width, innerWidth) {
@@ -722,7 +721,7 @@ export class NavigatorPanelWidget {
722
721
  lines.push(framedLine(previewRow ? previewRow.segments : [], width));
723
722
  }
724
723
  // Stacked, the panes share the full width and are told apart by rows.
725
- this.paneBands = stackedBands({ top: listTop, bottom: listBottom }, { top: previewTop, bottom: lines.length - 1 }, innerWidth);
724
+ this.paneBands = stackedBands({ top: listTop, bottom: listBottom }, { top: previewTop, bottom: lines.length - 1 }, innerWidth, this.frameInset);
726
725
  }
727
726
  previewHeaderText() {
728
727
  const entry = this.selectedEntry();
@@ -733,6 +732,8 @@ export class NavigatorPanelWidget {
733
732
  scroll: this.previewScroll,
734
733
  viewportRows: this.previewViewportRows,
735
734
  focused: this.focus === 'preview',
735
+ // One cell of the heading's column is the gutter between the two panes.
736
+ maxWidth: Math.max(8, (this.lastSplit?.previewWidth ?? 0) - 1),
736
737
  });
737
738
  }
738
739
  previewRenderLines() {