flint-agent 1.14.0

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 (171) hide show
  1. package/.env.example +108 -0
  2. package/CHANGELOG.md +55 -0
  3. package/FEATURES.md +298 -0
  4. package/LICENSE +21 -0
  5. package/README.md +435 -0
  6. package/bin/flint.js +47 -0
  7. package/config/classifier-prompt.md +218 -0
  8. package/config/models-curated.json +4 -0
  9. package/config/providers.json +74 -0
  10. package/package.json +92 -0
  11. package/patches/ink+6.8.0.patch +78 -0
  12. package/profiles/desktop.md +65 -0
  13. package/profiles/generic.md +20 -0
  14. package/profiles/marketer.md +20 -0
  15. package/profiles/profiles.json +34 -0
  16. package/profiles/ux-reviewer.md +25 -0
  17. package/src/agent/agent.js +1743 -0
  18. package/src/agent/auto.js +346 -0
  19. package/src/agent/backoff.js +143 -0
  20. package/src/agent/compression.js +310 -0
  21. package/src/agent/content-resolver.js +180 -0
  22. package/src/agent/flow-controller.js +309 -0
  23. package/src/agent/intent-manifest.js +231 -0
  24. package/src/agent/intent-timeout.js +46 -0
  25. package/src/agent/intent.js +633 -0
  26. package/src/agent/knowledge.js +114 -0
  27. package/src/agent/learning.js +180 -0
  28. package/src/agent/modes.js +187 -0
  29. package/src/agent/outcome-ask.js +91 -0
  30. package/src/agent/project-context.js +76 -0
  31. package/src/agent/prompt-budget.js +117 -0
  32. package/src/agent/reflection-extractor.js +140 -0
  33. package/src/agent/steering.js +86 -0
  34. package/src/agent/supervisor.js +430 -0
  35. package/src/agent/swap.js +443 -0
  36. package/src/agent/system-prompt.js +446 -0
  37. package/src/agent/time-stamp.js +48 -0
  38. package/src/agent/tool-guard.js +201 -0
  39. package/src/agent/toolcall-text.js +162 -0
  40. package/src/agent/usage.js +297 -0
  41. package/src/agent/vision.js +94 -0
  42. package/src/agent/watchdog.js +139 -0
  43. package/src/agent/workspace-changes.js +177 -0
  44. package/src/api/address.js +14 -0
  45. package/src/api/client.js +280 -0
  46. package/src/api/server.js +535 -0
  47. package/src/api/stream-pipe.js +113 -0
  48. package/src/app-state.js +39 -0
  49. package/src/bootstrap.js +501 -0
  50. package/src/bus/drain-loop.js +497 -0
  51. package/src/bus/index.js +270 -0
  52. package/src/bus/plugins.js +65 -0
  53. package/src/child-idle.js +14 -0
  54. package/src/cli.js +118 -0
  55. package/src/commands/commands.js +1297 -0
  56. package/src/commands/registry.js +132 -0
  57. package/src/components/App.js +491 -0
  58. package/src/components/CarefulMenu.js +145 -0
  59. package/src/components/HistoryWriter.js +86 -0
  60. package/src/components/LineInput.js +69 -0
  61. package/src/components/LiveZone.js +294 -0
  62. package/src/components/OverlayMenu.js +179 -0
  63. package/src/components/SystemPanel.js +156 -0
  64. package/src/components/Table.js +54 -0
  65. package/src/config.js +249 -0
  66. package/src/free-models.js +230 -0
  67. package/src/index.js +1111 -0
  68. package/src/input-handler.js +13 -0
  69. package/src/input-text.js +123 -0
  70. package/src/launcher.js +129 -0
  71. package/src/logging/api-log.js +95 -0
  72. package/src/logging/chat-log-follower.js +113 -0
  73. package/src/logging/chat-log.js +15 -0
  74. package/src/logging/log-collector.js +182 -0
  75. package/src/logging/logger.js +112 -0
  76. package/src/logging/tool-log.js +20 -0
  77. package/src/mcp-client.js +314 -0
  78. package/src/memory/conversation-digest.js +113 -0
  79. package/src/memory/extract-facts.js +98 -0
  80. package/src/memory/facts.js +181 -0
  81. package/src/memory/inbox.js +63 -0
  82. package/src/memory/markdown.js +38 -0
  83. package/src/memory/patterns.js +185 -0
  84. package/src/memory/project.js +66 -0
  85. package/src/memory/reflections.js +74 -0
  86. package/src/memory/retrieval.js +84 -0
  87. package/src/memory/rules.js +105 -0
  88. package/src/memory/session-facts.js +125 -0
  89. package/src/memory/skills.js +191 -0
  90. package/src/memory/sqlite-store.js +653 -0
  91. package/src/memory/store.js +208 -0
  92. package/src/memory/tools.js +196 -0
  93. package/src/memory/user-model.js +86 -0
  94. package/src/message-handler.js +775 -0
  95. package/src/model-check.js +218 -0
  96. package/src/plugins/loader.js +120 -0
  97. package/src/plugins/manager.js +88 -0
  98. package/src/production-env.js +22 -0
  99. package/src/profiles.js +42 -0
  100. package/src/providers/adapters/anthropic.js +270 -0
  101. package/src/providers/adapters/openai.js +120 -0
  102. package/src/providers/keys-dpapi.js +41 -0
  103. package/src/providers/keys-fallback.js +31 -0
  104. package/src/providers/keys.js +132 -0
  105. package/src/providers/models.js +154 -0
  106. package/src/providers/registry.js +56 -0
  107. package/src/providers/state.js +56 -0
  108. package/src/registry.js +96 -0
  109. package/src/restart.js +29 -0
  110. package/src/sandbox/backend.js +130 -0
  111. package/src/security/api-auth.js +132 -0
  112. package/src/security/audit.js +98 -0
  113. package/src/security/child-policy.js +41 -0
  114. package/src/security/command-guard.js +173 -0
  115. package/src/security/content-fence.js +250 -0
  116. package/src/security/content-validator.js +132 -0
  117. package/src/security/index.js +143 -0
  118. package/src/security/network-guard.js +126 -0
  119. package/src/security/pairing.js +180 -0
  120. package/src/security/path-guard.js +140 -0
  121. package/src/security/persona-guard.js +67 -0
  122. package/src/security/policies.js +452 -0
  123. package/src/security/safety-constants.js +34 -0
  124. package/src/security/watchdog.js +107 -0
  125. package/src/sessions.js +130 -0
  126. package/src/spend.js +97 -0
  127. package/src/startup-watchdog.js +59 -0
  128. package/src/stdio/args.js +71 -0
  129. package/src/stdio/guard.js +59 -0
  130. package/src/stdio/protocol.js +167 -0
  131. package/src/stdio/run.js +106 -0
  132. package/src/stdio/session.js +180 -0
  133. package/src/store/agent-slice.js +306 -0
  134. package/src/store/dataset-slice.js +73 -0
  135. package/src/store/index.js +22 -0
  136. package/src/store/process-slice.js +135 -0
  137. package/src/store/session-slice.js +191 -0
  138. package/src/store/ui-slice.js +119 -0
  139. package/src/tasks/db.js +184 -0
  140. package/src/tasks/queries.js +589 -0
  141. package/src/tools/agent-tools.js +473 -0
  142. package/src/tools/checkpoint.js +152 -0
  143. package/src/tools/command-approvals.js +180 -0
  144. package/src/tools/dataset.js +50 -0
  145. package/src/tools/filesystem.js +682 -0
  146. package/src/tools/inbox-tools.js +48 -0
  147. package/src/tools/mesh.js +135 -0
  148. package/src/tools/own-env.js +136 -0
  149. package/src/tools/permissions.js +681 -0
  150. package/src/tools/plugin-tools.js +123 -0
  151. package/src/tools/process-tools.js +595 -0
  152. package/src/tools/registry.js +307 -0
  153. package/src/tools/swap-tools.js +72 -0
  154. package/src/tools/system.js +662 -0
  155. package/src/tools/tasks.js +532 -0
  156. package/src/tools/tool-search.js +171 -0
  157. package/src/ui/header.js +140 -0
  158. package/src/ui/input-cursor.js +23 -0
  159. package/src/ui/last-line.js +25 -0
  160. package/src/ui/line-edit.js +135 -0
  161. package/src/ui/output.js +399 -0
  162. package/src/ui/paste-tokens.js +131 -0
  163. package/src/ui/prompt-attention.js +134 -0
  164. package/src/ui/render-options.js +13 -0
  165. package/src/ui/replay.js +94 -0
  166. package/src/ui/splash.js +49 -0
  167. package/src/ui/status-level.js +36 -0
  168. package/src/ui/tool-ledger.js +203 -0
  169. package/src/ui/window-title.js +150 -0
  170. package/src/update.js +205 -0
  171. package/system.md +63 -0
@@ -0,0 +1,132 @@
1
+ // Command registry -- tries to handle /commands, returns true if handled
2
+
3
+ import { registerCommands } from "./commands.js";
4
+
5
+ let commands = null;
6
+
7
+ export function initCommands(store) {
8
+ commands = registerCommands(store);
9
+ }
10
+
11
+ // Whether input is meant as a /command rather than a message to the agent.
12
+ // A leading "/" alone is not enough: "/work/match.mp4 is 12 seconds long..."
13
+ // is a task that starts with an absolute path, and treating it as a command
14
+ // answered "Unknown command" and never ran it. Over the API that reply has no
15
+ // messageId, so a caller polled for fifteen minutes in a benchmark run.
16
+ // Every command name is a slash and a word; a path has a second slash or a dot.
17
+ export function isSlashCommand(input) {
18
+ const first = String(input).trim().split(/\s/)[0];
19
+ return /^\/[a-z][a-z0-9_-]*$/i.test(first);
20
+ }
21
+
22
+ export async function tryHandleCommand(input, store) {
23
+ if (!commands) return false;
24
+
25
+ const trimmed = input.trim();
26
+ const lower = trimmed.toLowerCase();
27
+
28
+ // Direct match commands (no arguments) — includes /budget, /stats, etc.
29
+ if (commands[lower]) {
30
+ await commands[lower]();
31
+ return true;
32
+ }
33
+
34
+ // Commands with arguments
35
+ if (lower.startsWith("/load ")) {
36
+ const arg = trimmed.slice(6);
37
+ await commands["/load"](arg);
38
+ return true;
39
+ }
40
+ if (lower.startsWith("/profile ")) {
41
+ const arg = trimmed.slice(9);
42
+ await commands["/profile"](arg);
43
+ return true;
44
+ }
45
+ if (lower.startsWith("/allow ")) {
46
+ await commands["/allow"](trimmed.slice(7));
47
+ return true;
48
+ }
49
+ if (lower.startsWith("/deny ")) {
50
+ await commands["/deny"](trimmed.slice(6));
51
+ return true;
52
+ }
53
+ if (lower.startsWith("/confirm ")) {
54
+ await commands["/confirm"](trimmed.slice(9));
55
+ return true;
56
+ }
57
+ if (lower === "/memory clear") {
58
+ await commands["/memory clear"]();
59
+ return true;
60
+ }
61
+ if (lower === "/queue clear") {
62
+ await commands["/queue clear"]();
63
+ return true;
64
+ }
65
+ if (lower.startsWith("/later ")) {
66
+ await commands["/later"](trimmed.slice(7));
67
+ return true;
68
+ }
69
+ if (lower.startsWith("/tasks ")) {
70
+ await commands["/tasks"](trimmed.slice(7));
71
+ return true;
72
+ }
73
+ if (lower.startsWith("/rewind ")) {
74
+ await commands["/rewind"](trimmed.slice(8));
75
+ return true;
76
+ }
77
+ if (lower.startsWith("/model ")) {
78
+ await commands["/model"](trimmed.slice(7));
79
+ return true;
80
+ }
81
+ if (lower.startsWith("/provider ")) {
82
+ await commands["/provider"](trimmed.slice(10));
83
+ return true;
84
+ }
85
+ if (lower === "/provider") {
86
+ await commands["/provider"]();
87
+ return true;
88
+ }
89
+ if (lower.startsWith("/key ")) {
90
+ await commands["/key"](trimmed.slice(5));
91
+ return true;
92
+ }
93
+ if (lower === "/key") {
94
+ await commands["/key"]();
95
+ return true;
96
+ }
97
+ if (lower.startsWith("/install ")) {
98
+ await commands["/install"](trimmed.slice(9));
99
+ return true;
100
+ }
101
+ if (lower.startsWith("/uninstall ")) {
102
+ await commands["/uninstall"](trimmed.slice(11));
103
+ return true;
104
+ }
105
+ if (lower.startsWith("/page ")) {
106
+ await commands["/page"](trimmed.slice(6));
107
+ return true;
108
+ }
109
+ if (lower.startsWith("/auto ")) {
110
+ await commands["/auto"](trimmed.slice(6));
111
+ return true;
112
+ }
113
+ if (lower === "/auto") {
114
+ await commands["/auto"]();
115
+ return true;
116
+ }
117
+
118
+ // Any other known command with an argument: "/kill 3", "/logs 3",
119
+ // "/tools 10", "/careful permissive". Only the prefixes listed above used to
120
+ // get their argument, so a command added later answered "Unknown command"
121
+ // as soon as it was given one (owner, 2026-10-01).
122
+ const space = trimmed.search(/\s/);
123
+ if (space > 0) {
124
+ const name = lower.slice(0, space);
125
+ if (typeof commands[name] === "function") {
126
+ await commands[name](trimmed.slice(space + 1).trim());
127
+ return true;
128
+ }
129
+ }
130
+
131
+ return false;
132
+ }
@@ -0,0 +1,491 @@
1
+ // App: the console. History goes into the terminal's own scrollback
2
+ // (HistoryWriter); only a small live zone at the bottom is redrawn.
3
+ // docs/console-spec.md has the why: no tabs, no home-made scrolling, nothing
4
+ // that makes Ink's live output as tall as the window.
5
+ import { freeUsedToday, savedFreeLimit } from "../free-models.js";
6
+ import { config } from "../config.js";
7
+ import { getSpendLevel } from "../spend.js";
8
+ import React from "react";
9
+ import { Box, Text, useInput, useCursor } from "ink";
10
+ import stringWidth from "string-width";
11
+ import { LineInput } from "./LineInput.js";
12
+ import { absoluteTop, CURSOR_BAR, CURSOR_DEFAULT } from "../ui/input-cursor.js";
13
+ import { cursorCell, insertAt, wrapRows } from "../ui/line-edit.js";
14
+ import { normalizeInputText } from "../input-text.js";
15
+ import { applyInsert, expandPastes, imageIds, previewPastes, composeRows } from "../ui/paste-tokens.js";
16
+ import { HistoryWriter } from "./HistoryWriter.js";
17
+ import { LiveZoneTop, StatusLine, DOTS_FRAME_MS } from "./LiveZone.js";
18
+ import { OverlayMenu } from "./OverlayMenu.js";
19
+ import chalk from "chalk";
20
+
21
+ const { createElement: h, useSyncExternalStore, useState, useRef, useCallback, useEffect } = React;
22
+
23
+ // Rows the /model and /provider pick lists may take. They scroll within this.
24
+ const OVERLAY_MAX_ROWS = 10;
25
+ // Two Ctrl+C presses this close together exit Flint.
26
+ const CTRL_C_EXIT_MS = 2000;
27
+
28
+ // Stable selector — returns cached object if shallow-equal
29
+ function useStoreSelector(store, selector) {
30
+ const prev = useRef(null);
31
+ return useSyncExternalStore(store.subscribe, () => {
32
+ const next = selector(store.getState());
33
+ if (prev.current !== null && shallowEqual(prev.current, next)) {
34
+ return prev.current;
35
+ }
36
+ prev.current = next;
37
+ return next;
38
+ });
39
+ }
40
+
41
+ function shallowEqual(a, b) {
42
+ if (a === b) return true;
43
+ if (!a || !b) return false;
44
+ const keysA = Object.keys(a);
45
+ if (keysA.length !== Object.keys(b).length) return false;
46
+ for (const k of keysA) {
47
+ if (a[k] !== b[k]) return false;
48
+ }
49
+ return true;
50
+ }
51
+
52
+ export function App({ store, onSubmit, onAbort, onQuit, onClipboard, onRecallQueued }) {
53
+ // The line and where its cursor is (LineInput edits both).
54
+ const [edit, setEdit] = useState({ value: "", cursor: 0 });
55
+ const input = edit.value;
56
+ // Replace the whole line, cursor at the end (history, clear, recall).
57
+ const setInput = (text) => setEdit({ value: text, cursor: text.length });
58
+
59
+ const historyIdxRef = useRef(-1);
60
+ const savedInputRef = useRef("");
61
+ const inputRef = useRef("");
62
+ inputRef.current = input;
63
+ const editRef = useRef(edit);
64
+ editRef.current = edit;
65
+
66
+ // Pasted text and images stand in the input as tokens (ui/paste-tokens.js).
67
+ // Kept for the whole session, so a message recalled with Up still expands.
68
+ const pastesRef = useRef(new Map());
69
+ const pasteStateRef = useRef({ nextId: 1, last: null });
70
+ const imagesRef = useRef(new Map());
71
+
72
+ // Text arriving at the cursor. A paste on Windows arrives with bare CRs;
73
+ // normalized here so the line, the cursor arithmetic and the submitted
74
+ // message are one string. A big chunk becomes a paste token. Not
75
+ // while a secret is typed: an API key must go to /key as it is.
76
+ const handleInsert = (chunk, state) => {
77
+ const text = normalizeInputText(chunk);
78
+ if (store.getState().secretPrompt) return insertAt(state, text);
79
+ return applyInsert({ ...state, chunk: text, pastes: pastesRef.current, state: pasteStateRef.current });
80
+ };
81
+ const lastCtrlC = useRef(0);
82
+ const exitWarnedRef = useRef(false);
83
+
84
+ const ui = useStoreSelector(store, (s) => ({
85
+ inputHistory: s.inputHistory,
86
+ streamText: s.streamText,
87
+ model: s.model,
88
+ contextTokens: s.lastContextTokens,
89
+ contextEstimated: s.contextEstimated || false,
90
+ contextLimit: s.contextLimit,
91
+ sessionCost: s.sessionCost,
92
+ sessionCostEstimated: s.sessionCostEstimated || false,
93
+ queued: s.processingCount > 1 ? s.processingCount - 1 : 0,
94
+ processes: s.processes,
95
+ pendingConfirmation: s.pendingConfirmation ? s.pendingConfirmation.toolName : null,
96
+ pendingConfirmationArgs: s.pendingConfirmation ? s.pendingConfirmation.argsText || null : null,
97
+ pendingPairing: s.pendingPairing || null,
98
+ autoMode: s.autoMode || null,
99
+ overlay: s.overlay || null,
100
+ // Set by /key: the next submitted line is a secret for that resolver only.
101
+ secretPrompt: s.secretPrompt || null,
102
+ agentStatus: s.agentStatus || "idle",
103
+ currentTool: s.currentTool || null,
104
+ iterationCount: s._iterationCount || 0,
105
+ startedAt: s._startedAt || null,
106
+ activity: s.activity || null,
107
+ activityStartedAt: s.activityStartedAt || null,
108
+ activityDetail: s.activityDetail || null,
109
+ activityTokens: s.activityTokens || 0,
110
+ queuedInputs: s.queuedInputs || [],
111
+ // Changed by /careful; read here only so the status line redraws.
112
+ careLevel: s._careLevel || null,
113
+ spendLevel: s._spendLevel || getSpendLevel(),
114
+ // Free mode: requests made today with a free model, of the daily limit.
115
+ freeQuota: config.freeChain ? { used: freeUsedToday(), limit: s._freeLimit || savedFreeLimit() } : null,
116
+ }));
117
+ const { inputHistory } = ui;
118
+
119
+ // Re-render while busy, fast enough for the running dots to move.
120
+ const [, setTick] = useState(0);
121
+ useEffect(() => {
122
+ if (ui.agentStatus === "idle") return undefined;
123
+ const t = setInterval(() => setTick((n) => n + 1), DOTS_FRAME_MS);
124
+ t.unref?.();
125
+ return () => clearInterval(t);
126
+ }, [ui.agentStatus]);
127
+
128
+ const handleSubmit = useCallback((value) => {
129
+ setInput("");
130
+ historyIdxRef.current = -1;
131
+ savedInputRef.current = "";
132
+ // Secret mode (/key): the line goes to the waiting command only. It never
133
+ // reaches onSubmit, so it is not sent to the model, not echoed into the
134
+ // chat, and not added to input history.
135
+ const secret = store.getState().secretPrompt;
136
+ if (secret) {
137
+ store.setState({ secretPrompt: null });
138
+ secret.resolve(value);
139
+ return;
140
+ }
141
+ // The model gets the full pasted text; the history line shows each paste
142
+ // opened into its first lines; Up recalls the tokens.
143
+ const images = imageIds(value).map((id) => imagesRef.current.get(id)).filter(Boolean);
144
+ if (onSubmit) {
145
+ onSubmit(expandPastes(value, pastesRef.current), {
146
+ display: previewPastes(value, pastesRef.current),
147
+ history: value,
148
+ images,
149
+ });
150
+ }
151
+ }, [onSubmit, store]);
152
+
153
+ const handleOverlaySelect = useCallback(async (item) => {
154
+ const s = store.getState();
155
+ const overlay = s.overlay;
156
+ if (!overlay) return;
157
+
158
+ if (overlay.type === "provider") {
159
+ // Drill into models for selected provider
160
+ s.overlaySetLoading(true);
161
+ try {
162
+ const { fetchModels } = await import("../providers/models.js");
163
+ const { hasKey } = await import("../providers/keys.js");
164
+ const { getProvider } = await import("../providers/registry.js");
165
+ const { readFileSync, existsSync } = await import("node:fs");
166
+ const { homedir } = await import("node:os");
167
+ const { join, dirname } = await import("node:path");
168
+ const { fileURLToPath } = await import("node:url");
169
+ const { config } = await import("../config.js");
170
+
171
+ const provider = getProvider(item.id);
172
+ if (provider.keyRequired && !hasKey(item.id)) {
173
+ s.addLine(` No API key for ${provider.name}. Use /key ${item.id} to add one.`);
174
+ s.closeOverlay();
175
+ return;
176
+ }
177
+
178
+ let models = await fetchModels(item.id);
179
+
180
+ // Apply curated filter for openrouter (only if user has non-empty ~/.flint/models-curated.json)
181
+ // Project default is empty [] = show all models
182
+ if (item.id === "openrouter") {
183
+ try {
184
+ // homedir(), not process.env.HOME: HOME is unset on Windows.
185
+ const userPath = join(homedir(), ".flint", "models-curated.json");
186
+ if (existsSync(userPath)) {
187
+ const data = JSON.parse(readFileSync(userPath, "utf-8"));
188
+ const prefixes = Array.isArray(data[item.id]) ? data[item.id] : [];
189
+ if (prefixes.length > 0) {
190
+ models = models.filter((m) => prefixes.some((px) => m.id.startsWith(px)));
191
+ }
192
+ }
193
+ } catch {}
194
+ }
195
+
196
+ if (!models.length) {
197
+ s.addLine(` No models returned from ${provider.name}. Check your API key and network.`);
198
+ s.closeOverlay();
199
+ return;
200
+ }
201
+
202
+ models.sort((a, b) => a.id.localeCompare(b.id));
203
+
204
+ const modelItems = models.map((m) => {
205
+ const ctx = m.context_length ? `${(m.context_length / 1000).toFixed(0)}k` : "";
206
+ const price = m.pricing
207
+ ? `$${(m.pricing.prompt * 1e6).toFixed(2)} / $${(m.pricing.completion * 1e6).toFixed(2)}`
208
+ : "";
209
+ return {
210
+ id: m.id,
211
+ ctx,
212
+ price,
213
+ current: m.id === config.model,
214
+ providerId: item.id,
215
+ _priceNum: m.pricing ? m.pricing.prompt * 1e6 : Infinity,
216
+ _ctxNum: m.context_length || 0,
217
+ };
218
+ });
219
+
220
+ s.overlayDrilldown("model", `Models -- ${provider.name}`, modelItems, {
221
+ providerId: item.id,
222
+ providerName: provider.name,
223
+ });
224
+ } catch (err) {
225
+ s.addLine(` Error loading models: ${err.message}`);
226
+ s.closeOverlay();
227
+ }
228
+ } else if (overlay.type === "model") {
229
+ // Apply model + provider selection
230
+ try {
231
+ const { config } = await import("../config.js");
232
+ const { setActiveProvider, setLastModel } = await import("../providers/state.js");
233
+ const { getProvider } = await import("../providers/registry.js");
234
+ const { fetchModelInfo } = await import("../api/client.js");
235
+
236
+ const providerId = item.providerId || overlay.parent?.providerId;
237
+ const provider = getProvider(providerId);
238
+
239
+ config.provider = providerId;
240
+ setActiveProvider(providerId);
241
+ config.model = item.id;
242
+ setLastModel(providerId, item.id);
243
+ s.setModel(item.id);
244
+ s.setProvider(providerId);
245
+ await config.resolveApiKey();
246
+
247
+ const info = await fetchModelInfo(item.id);
248
+ if (info) s.setPricing(info);
249
+
250
+ s.closeOverlay();
251
+ s.addLine(` Switched to ${provider.name} / ${item.id}`);
252
+ } catch (err) {
253
+ s.addLine(` Error: ${err.message}`);
254
+ s.closeOverlay();
255
+ }
256
+ } else if (overlay.type === "free") {
257
+ // Free mode: this model and two fallbacks from other vendors.
258
+ s.closeOverlay();
259
+ const { applyFreeChain } = await import("../free-models.js");
260
+ await applyFreeChain({ chain: item.chain, limit: item.limit, store, log: (l) => s.addLine(` ${l}`) });
261
+ } else if (overlay.type === "session") {
262
+ // /resume: continue the picked session, the way /load does.
263
+ s.closeOverlay();
264
+ const { tryHandleCommand } = await import("../commands/registry.js");
265
+ await tryHandleCommand(`/load ${item.id}`, store);
266
+ }
267
+ }, [store]);
268
+
269
+
270
+ useInput((ch, key) => {
271
+ // Pick list (/model, /provider) owns the keyboard while open.
272
+ if (ui.overlay) {
273
+ const s = store.getState();
274
+ const o = s.overlay;
275
+ if (!o || o.loading) return;
276
+ if (key.upArrow) { s.overlaySetIndex(Math.max(0, o.index - 1)); return; }
277
+ if (key.downArrow) { s.overlaySetIndex(Math.min((o.items?.length || 1) - 1, o.index + 1)); return; }
278
+ if (key.return) {
279
+ const item = o.items?.[o.index];
280
+ if (item) handleOverlaySelect(item);
281
+ return;
282
+ }
283
+ if (key.escape) {
284
+ if (o.parent) { if (onSubmit) onSubmit("/provider"); } else { s.closeOverlay(); }
285
+ return;
286
+ }
287
+ // Ctrl+S cycles the sort mode (name, price, context)
288
+ if (ch === "\x13" || (key.ctrl && ch === "s")) { s.overlayCycleSort(); return; }
289
+ return;
290
+ }
291
+
292
+ // Ctrl+C: stop the turn; a second press within 2 s exits. Idle with text
293
+ // in the input, it clears the input first.
294
+ if (key.ctrl && ch === "c") {
295
+ const now = Date.now();
296
+ if (now - lastCtrlC.current < CTRL_C_EXIT_MS) {
297
+ // Background processes die with Flint: say so once, and exit on the
298
+ // next press (docs/console-spec.md, "after asking only if background
299
+ // processes are still running").
300
+ const running = (store.getState().processes || []).filter((p) => p.status === "running").length;
301
+ if (running && !exitWarnedRef.current) {
302
+ exitWarnedRef.current = true;
303
+ lastCtrlC.current = now;
304
+ store.getState().addLine(chalk.yellow(` ${running} background process${running === 1 ? "" : "es"} still running. Ctrl+C again to stop ${running === 1 ? "it" : "them"} and exit.`));
305
+ return;
306
+ }
307
+ onQuit?.();
308
+ return;
309
+ }
310
+ exitWarnedRef.current = false;
311
+ lastCtrlC.current = now;
312
+ const s = store.getState();
313
+ if (s.secretPrompt) {
314
+ const secret = s.secretPrompt;
315
+ store.setState({ secretPrompt: null });
316
+ secret.resolve("");
317
+ }
318
+ if (inputRef.current) { setInput(""); historyIdxRef.current = -1; return; }
319
+ if (s.agentStatus && s.agentStatus !== "idle") onAbort?.();
320
+ s.addLine(chalk.gray(" Press Ctrl+C again to exit."));
321
+ return;
322
+ }
323
+
324
+ // A pending approval is answered with one key: y, n or a. Esc means no.
325
+ // The text input is not focused meanwhile, so the key is not typed.
326
+ if (store.getState().pendingConfirmation) {
327
+ const k = (ch || "").toLowerCase();
328
+ if (k === "y" || k === "n" || k === "a") onSubmit?.(k);
329
+ else if (key.escape) onSubmit?.("n");
330
+ return;
331
+ }
332
+
333
+ if (key.escape && store.getState().secretPrompt) {
334
+ // Esc cancels a secret prompt: resolved empty, which /key reports as Cancelled.
335
+ const secret = store.getState().secretPrompt;
336
+ store.setState({ secretPrompt: null });
337
+ setInput("");
338
+ secret.resolve("");
339
+ return;
340
+ }
341
+ // Esc: clear the input if there is text; else take the newest unread
342
+ // message back for editing; else stop the turn, then background work.
343
+ if (key.escape) {
344
+ if (inputRef.current) { setInput(""); historyIdxRef.current = -1; return; }
345
+ if ((store.getState().queuedInputs || []).length) {
346
+ const text = onRecallQueued?.();
347
+ if (text) { setInput(text); return; }
348
+ }
349
+ onAbort?.();
350
+ return;
351
+ }
352
+
353
+ if (key.upArrow) {
354
+ const hist = inputHistory;
355
+ if (!hist.length) return;
356
+ if (historyIdxRef.current === -1) {
357
+ savedInputRef.current = inputRef.current;
358
+ historyIdxRef.current = hist.length - 1;
359
+ } else if (historyIdxRef.current > 0) {
360
+ historyIdxRef.current--;
361
+ }
362
+ setInput(hist[historyIdxRef.current]);
363
+ return;
364
+ }
365
+ if (key.downArrow) {
366
+ if (historyIdxRef.current === -1) return;
367
+ const hist = inputHistory;
368
+ if (historyIdxRef.current < hist.length - 1) {
369
+ historyIdxRef.current++;
370
+ setInput(hist[historyIdxRef.current]);
371
+ } else {
372
+ historyIdxRef.current = -1;
373
+ setInput(savedInputRef.current);
374
+ }
375
+ return;
376
+ }
377
+ // Ctrl+U and Ctrl+W are LineInput's: they edit at the cursor.
378
+ // Ctrl+T: print the thinking blocks of this session into history.
379
+ if (ch === "\x14" || (key.ctrl && ch === "t")) {
380
+ const st = store.getState();
381
+ if (!st.thoughts.length) return;
382
+ st.toggleThoughts();
383
+ if (store.getState().showThoughts) {
384
+ for (const t of st.thoughts) {
385
+ st.addLine(chalk.gray(` -- T[${t.id}] --`));
386
+ const lines = t.text.split("\n");
387
+ for (const l of lines.slice(0, 10)) st.addLine(chalk.gray(` ${l}`));
388
+ if (lines.length > 10) st.addLine(chalk.gray(" ..."));
389
+ }
390
+ } else {
391
+ st.addLine(chalk.gray(" [thinking blocks hidden]"));
392
+ }
393
+ return;
394
+ }
395
+ // Ctrl+V / Alt+V: the clipboard goes into the input as a token, picture or
396
+ // text, and is sent with whatever is typed around it (it used to be sent
397
+ // at once, with "What is in this image?" if nothing had been typed).
398
+ if ((key.ctrl && ch === "v") || (key.meta && ch === "v")) {
399
+ const clip = onClipboard?.();
400
+ if (!clip) { store.getState().addLine(chalk.gray(" Clipboard is empty.")); return; }
401
+ // At the cursor, like any other insert.
402
+ const at = editRef.current;
403
+ const sep = at.cursor > 0 && !/\s$/.test(at.value.slice(0, at.cursor)) ? " " : "";
404
+ if (clip.type === "image") {
405
+ imagesRef.current.set(clip.index, clip);
406
+ setEdit(insertAt(at, `${sep}[Image #${clip.index}] `));
407
+ } else {
408
+ setEdit(handleInsert(sep + clip.data, at));
409
+ }
410
+ }
411
+ });
412
+
413
+ const cols = process.stdout.columns || 80;
414
+ const rows = process.stdout.rows || 24;
415
+
416
+ // The real terminal cursor, as a thin bar where the line's cursor is. The
417
+ // input row's position comes from the layout Ink computed for the previous
418
+ // frame; when it moves, the effect below stores the new row and one more
419
+ // render puts the cursor there. LineInput wraps the line character by
420
+ // character, so the cell computed here is the one it draws.
421
+ const { setCursorPosition } = useCursor();
422
+ const inputRowRef = useRef(null);
423
+ const [inputTop, setInputTop] = useState(null);
424
+ const promptText = ui.secretPrompt ? `${ui.secretPrompt.label} (hidden, Esc to cancel): ` : "> ";
425
+ const promptWidth = stringWidth(promptText);
426
+ const lineWidth = Math.max(10, cols - 1 - promptWidth);
427
+ const drawnInput = ui.secretPrompt ? "*".repeat(input.length) : input;
428
+ // Height budget (owner, 2026-10-01): Ink reprints the whole session when its
429
+ // live output reaches the window height. The input shows at most 40% of the
430
+ // window (one row while a question waits), around the cursor; the live zone
431
+ // gets what is left after the two rules and the status line.
432
+ const cell = cursorCell(drawnInput, edit.cursor, lineWidth);
433
+ const inputRows = wrapRows(drawnInput, lineWidth).length;
434
+ const maxInputRows = ui.pendingConfirmation ? 1 : Math.max(1, Math.floor(rows * 0.4));
435
+ const shownInputRows = Math.min(inputRows, maxInputRows);
436
+ const rowStart = Math.max(0, Math.min(cell.row - shownInputRows + 1, inputRows - shownInputRows));
437
+ const liveBudget = Math.max(0, rows - shownInputRows - 4);
438
+ if (inputTop !== null && !ui.overlay) {
439
+ setCursorPosition({ x: promptWidth + cell.col, y: inputTop + cell.row - rowStart });
440
+ } else {
441
+ setCursorPosition(undefined);
442
+ }
443
+ useEffect(() => {
444
+ const top = absoluteTop(inputRowRef.current);
445
+ if (top !== null && top !== inputTop) setInputTop(top);
446
+ });
447
+ // Bar shape while Flint owns the terminal, the terminal's default after.
448
+ useEffect(() => {
449
+ if (!process.stdout.isTTY) return undefined;
450
+ process.stdout.write(CURSOR_BAR);
451
+ const restore = () => { try { process.stdout.write(CURSOR_DEFAULT); } catch {} };
452
+ process.on("exit", restore);
453
+ return () => { process.off("exit", restore); restore(); };
454
+ }, []);
455
+
456
+ // The pick lists take the live zone's place and its budget.
457
+ const overlayHeight = Math.max(1, Math.min(OVERLAY_MAX_ROWS, liveBudget));
458
+
459
+ return h(
460
+ Box,
461
+ { flexDirection: "column" },
462
+ h(HistoryWriter, { store }),
463
+ ui.overlay
464
+ ? h(OverlayMenu, { overlay: ui.overlay, height: overlayHeight })
465
+ : h(LiveZoneTop, { state: ui, budget: liveBudget, pasteRows: ui.secretPrompt ? [] : composeRows(input, pastesRef.current) }),
466
+ h(Box, null, h(Text, { dimColor: true }, "-".repeat(cols))),
467
+ h(Box, { ref: inputRowRef },
468
+ ui.secretPrompt
469
+ ? h(Text, { color: "yellow" }, promptText)
470
+ : h(Text, { color: "green" }, promptText),
471
+ // No drawn caret: the real cursor is placed above, wherever the line's
472
+ // cursor is. Arrows, Ctrl+arrows, Home/End edit anywhere in the line.
473
+ h(LineInput, {
474
+ value: edit.value,
475
+ cursor: edit.cursor,
476
+ onChange: setEdit,
477
+ onInsert: handleInsert,
478
+ onSubmit: handleSubmit,
479
+ focus: !ui.pendingConfirmation,
480
+ mask: ui.secretPrompt ? "*" : undefined,
481
+ width: lineWidth,
482
+ rowStart,
483
+ maxRows: shownInputRows,
484
+ }),
485
+ ),
486
+ // A rule under the input too, so the status line reads as a footer and
487
+ // not as part of what is being typed (owner, 2026-10-01).
488
+ h(Box, null, h(Text, { dimColor: true }, "-".repeat(cols))),
489
+ h(StatusLine, { state: ui }),
490
+ );
491
+ }