@giovannijecha/jecode 0.1.5-rc.2

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 (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +264 -0
  3. package/bin/jecode.js +11 -0
  4. package/dist/atomic.js +78 -0
  5. package/dist/batch-view.js +17 -0
  6. package/dist/batch.js +100 -0
  7. package/dist/cli-info.js +40 -0
  8. package/dist/commands.js +171 -0
  9. package/dist/config.js +97 -0
  10. package/dist/controller.js +90 -0
  11. package/dist/credential-commands.js +134 -0
  12. package/dist/credential-safety.js +86 -0
  13. package/dist/credentials.js +124 -0
  14. package/dist/main.js +7 -0
  15. package/dist/ollama-settings-command.js +75 -0
  16. package/dist/prompt.js +29 -0
  17. package/dist/provider-commands.js +236 -0
  18. package/dist/provider-errors.js +10 -0
  19. package/dist/providers/anthropic-stream.js +111 -0
  20. package/dist/providers/anthropic-wire.js +79 -0
  21. package/dist/providers/anthropic.js +77 -0
  22. package/dist/providers/catalog.js +37 -0
  23. package/dist/providers/http.js +219 -0
  24. package/dist/providers/index.js +18 -0
  25. package/dist/providers/ollama-endpoint.js +40 -0
  26. package/dist/providers/ollama-stream.js +60 -0
  27. package/dist/providers/ollama-wire.js +95 -0
  28. package/dist/providers/ollama.js +87 -0
  29. package/dist/providers/openai-stream.js +48 -0
  30. package/dist/providers/openai-wire.js +112 -0
  31. package/dist/providers/openai.js +70 -0
  32. package/dist/providers/sse.js +81 -0
  33. package/dist/providers/stream-limits.js +11 -0
  34. package/dist/session.js +3 -0
  35. package/dist/settings-command.js +202 -0
  36. package/dist/settings.js +98 -0
  37. package/dist/start.js +47 -0
  38. package/dist/tools/args.js +38 -0
  39. package/dist/tools/fs.js +277 -0
  40. package/dist/tools/index.js +39 -0
  41. package/dist/tools/paths.js +122 -0
  42. package/dist/tools/search.js +213 -0
  43. package/dist/tools/shell.js +139 -0
  44. package/dist/tools/text-boundary.js +102 -0
  45. package/dist/tools/types.js +1 -0
  46. package/dist/transcript-export.js +11 -0
  47. package/dist/transcript.js +49 -0
  48. package/dist/tui/activity.js +11 -0
  49. package/dist/tui/app-input.js +155 -0
  50. package/dist/tui/app-state.js +17 -0
  51. package/dist/tui/app-workflows.js +105 -0
  52. package/dist/tui/app.js +215 -0
  53. package/dist/tui/approve.js +67 -0
  54. package/dist/tui/blocks.js +23 -0
  55. package/dist/tui/complete.js +54 -0
  56. package/dist/tui/components/command-menu.js +17 -0
  57. package/dist/tui/components/composer.js +47 -0
  58. package/dist/tui/components/dock.js +10 -0
  59. package/dist/tui/components/footer.js +21 -0
  60. package/dist/tui/components/menu.js +38 -0
  61. package/dist/tui/components/messages.js +43 -0
  62. package/dist/tui/components/misc.js +22 -0
  63. package/dist/tui/components/status.js +45 -0
  64. package/dist/tui/components/tool.js +85 -0
  65. package/dist/tui/components/types.js +1 -0
  66. package/dist/tui/editor.js +105 -0
  67. package/dist/tui/feedback.js +74 -0
  68. package/dist/tui/field.js +60 -0
  69. package/dist/tui/frame.js +33 -0
  70. package/dist/tui/input.js +52 -0
  71. package/dist/tui/keys.js +177 -0
  72. package/dist/tui/modal.js +24 -0
  73. package/dist/tui/overlay.js +93 -0
  74. package/dist/tui/picker.js +99 -0
  75. package/dist/tui/screen.js +94 -0
  76. package/dist/tui/scroll.js +7 -0
  77. package/dist/tui/session-view.js +49 -0
  78. package/dist/tui/transcript-view.js +134 -0
  79. package/dist/tui/turn.js +255 -0
  80. package/dist/tui/view.js +88 -0
  81. package/dist/tui/workspace.js +59 -0
  82. package/dist/types.js +9 -0
  83. package/dist/ui/diff.js +109 -0
  84. package/dist/ui/highlight.js +158 -0
  85. package/dist/ui/inline.js +39 -0
  86. package/dist/ui/markdown.js +147 -0
  87. package/dist/ui/render.js +232 -0
  88. package/dist/ui/table.js +127 -0
  89. package/dist/ui/terminal-text.js +42 -0
  90. package/dist/ui/theme.js +25 -0
  91. package/dist/ui/width.js +196 -0
  92. package/dist/usage.js +30 -0
  93. package/dist/user-data.js +29 -0
  94. package/package.json +56 -0
@@ -0,0 +1,139 @@
1
+ // Shell tool. One command, captured output, a timeout, and a hard cap on how
2
+ // much of it comes back.
3
+ import { spawn } from "node:child_process";
4
+ import { optionalInt, requireString } from "./args.js";
5
+ import { credentialRedactor, redactCredentials, shellEnvironment } from "../credential-safety.js";
6
+ const DEFAULT_TIMEOUT_MS = 120_000;
7
+ const MAX_OUTPUT_CHARS = 30_000;
8
+ export const runCommand = {
9
+ name: "run_command",
10
+ description: "Run a shell command starting in the workspace root and return its combined stdout " +
11
+ "and stderr. The shell is not a filesystem sandbox, so every call requires approval. Output is " +
12
+ "truncated past 30000 characters.",
13
+ dangerous: true,
14
+ input: {
15
+ type: "object",
16
+ properties: {
17
+ command: { type: "string", description: "The command line to run." },
18
+ timeout_ms: { type: "integer", description: "Kill the command after this long. Defaults to 120000." },
19
+ },
20
+ required: ["command"],
21
+ },
22
+ async run(args, ctx) {
23
+ const command = requireString(args, "command");
24
+ const timeoutMs = optionalInt(args, "timeout_ms") ?? DEFAULT_TIMEOUT_MS;
25
+ if (timeoutMs <= 0)
26
+ throw new Error('"timeout_ms" must be a positive integer');
27
+ const result = await execute(command, ctx.root, timeoutMs, ctx.signal);
28
+ const output = redactCredentials(result.output);
29
+ const summary = result.timedOut
30
+ ? `timed out after ${timeoutMs}ms`
31
+ : `exit ${result.code ?? "?"}`;
32
+ return {
33
+ output: output === "" ? `[${summary}]` : `${output}\n[${summary}]`,
34
+ summary,
35
+ };
36
+ },
37
+ };
38
+ function execute(command, cwd, timeoutMs, signal) {
39
+ if (signal?.aborted === true)
40
+ return Promise.reject(abortReason(signal));
41
+ return new Promise((resolve, reject) => {
42
+ const child = spawn(command, {
43
+ cwd,
44
+ env: shellEnvironment(),
45
+ shell: true,
46
+ windowsHide: true,
47
+ detached: process.platform !== "win32",
48
+ });
49
+ const output = capture();
50
+ let timedOut = false;
51
+ let aborted;
52
+ let settled = false;
53
+ let forceTimer;
54
+ const timer = setTimeout(() => {
55
+ timedOut = true;
56
+ stopTree(child.pid, false);
57
+ forceTimer = setTimeout(() => stopTree(child.pid, true), 500);
58
+ }, timeoutMs);
59
+ const onAbort = () => {
60
+ aborted = signal === undefined ? new Error("aborted") : abortReason(signal);
61
+ stopTree(child.pid, false);
62
+ forceTimer = setTimeout(() => stopTree(child.pid, true), 500);
63
+ };
64
+ signal?.addEventListener("abort", onAbort, { once: true });
65
+ const cleanup = () => {
66
+ clearTimeout(timer);
67
+ if (forceTimer !== undefined)
68
+ clearTimeout(forceTimer);
69
+ signal?.removeEventListener("abort", onAbort);
70
+ };
71
+ child.stdout.setEncoding("utf8");
72
+ child.stderr.setEncoding("utf8");
73
+ child.stdout.on("data", output.append);
74
+ child.stderr.on("data", output.append);
75
+ child.on("error", (error) => {
76
+ if (settled)
77
+ return;
78
+ settled = true;
79
+ cleanup();
80
+ reject(error);
81
+ });
82
+ child.on("close", (code) => {
83
+ if (settled)
84
+ return;
85
+ settled = true;
86
+ cleanup();
87
+ if (aborted !== undefined)
88
+ reject(aborted);
89
+ else
90
+ resolve({ output: output.value().trimEnd(), code, timedOut });
91
+ });
92
+ });
93
+ }
94
+ function capture() {
95
+ const half = MAX_OUTPUT_CHARS / 2;
96
+ const credentials = credentialRedactor();
97
+ let head = "";
98
+ let tail = "";
99
+ let total = 0;
100
+ const append = (chunk) => {
101
+ total += chunk.length;
102
+ const room = Math.max(0, half - head.length);
103
+ head += chunk.slice(0, room);
104
+ const rest = chunk.slice(room);
105
+ if (rest !== "")
106
+ tail = `${tail}${rest}`.slice(-half);
107
+ };
108
+ return {
109
+ append(chunk) {
110
+ append(credentials.write(chunk));
111
+ },
112
+ value() {
113
+ append(credentials.end());
114
+ if (total <= MAX_OUTPUT_CHARS)
115
+ return `${head}${tail}`;
116
+ const cut = total - head.length - tail.length;
117
+ return `${head}\n\n[... ${cut} characters cut ...]\n\n${tail}`;
118
+ },
119
+ };
120
+ }
121
+ function abortReason(signal) {
122
+ return signal.reason instanceof Error ? signal.reason : new Error("aborted");
123
+ }
124
+ function stopTree(pid, force) {
125
+ if (pid === undefined)
126
+ return;
127
+ if (process.platform === "win32") {
128
+ const args = ["/pid", String(pid), "/T", ...(force ? ["/F"] : [])];
129
+ const killer = spawn("taskkill", args, { windowsHide: true, stdio: "ignore" });
130
+ killer.on("error", () => undefined);
131
+ return;
132
+ }
133
+ try {
134
+ process.kill(-pid, force ? "SIGKILL" : "SIGTERM");
135
+ }
136
+ catch {
137
+ // The process may have exited between the timeout and this signal.
138
+ }
139
+ }
@@ -0,0 +1,102 @@
1
+ // Shared limits for tools that need to hold an entire text file in memory.
2
+ import { constants } from "node:fs";
3
+ import { lstat, open } from "node:fs/promises";
4
+ export const MAX_EDITABLE_BYTES = 4_000_000;
5
+ export const MAX_EDITABLE_CHARS = 1_000_000;
6
+ export const MAX_EDITABLE_LINES = 20_000;
7
+ const READ_CHUNK_BYTES = 64 * 1024;
8
+ /** Read a regular UTF-8 file without allowing an unbounded allocation. */
9
+ export async function readEditableText(file, options = {}) {
10
+ const label = options.label ?? "file";
11
+ let details;
12
+ try {
13
+ details = await lstat(file);
14
+ }
15
+ catch (error) {
16
+ if (options.missingAsEmpty === true && isMissing(error))
17
+ return "";
18
+ throw error;
19
+ }
20
+ if (!details.isFile())
21
+ throw new Error(`${label} must be a regular file`);
22
+ let handle;
23
+ try {
24
+ const flags = process.platform === "win32"
25
+ ? "r"
26
+ : constants.O_RDONLY | (constants.O_NONBLOCK ?? 0);
27
+ handle = await open(file, flags);
28
+ }
29
+ catch (error) {
30
+ if (options.missingAsEmpty === true && isMissing(error))
31
+ return "";
32
+ throw error;
33
+ }
34
+ try {
35
+ const stat = await handle.stat();
36
+ if (!stat.isFile())
37
+ throw new Error(`${label} must be a regular file`);
38
+ if (stat.size > MAX_EDITABLE_BYTES) {
39
+ throw limitError(label, `${MAX_EDITABLE_BYTES} UTF-8 bytes`);
40
+ }
41
+ const chunks = [];
42
+ let total = 0;
43
+ while (total <= MAX_EDITABLE_BYTES) {
44
+ const room = MAX_EDITABLE_BYTES + 1 - total;
45
+ const buffer = Buffer.allocUnsafe(Math.min(READ_CHUNK_BYTES, room));
46
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, total);
47
+ if (bytesRead === 0)
48
+ break;
49
+ chunks.push(buffer.subarray(0, bytesRead));
50
+ total += bytesRead;
51
+ }
52
+ if (total > MAX_EDITABLE_BYTES) {
53
+ throw limitError(label, `${MAX_EDITABLE_BYTES} UTF-8 bytes`);
54
+ }
55
+ const text = Buffer.concat(chunks, total).toString("utf8");
56
+ assertEditableText(text, label);
57
+ return text;
58
+ }
59
+ finally {
60
+ await handle.close();
61
+ }
62
+ }
63
+ /** Reject whole-file writes that exceed Jecode's mutation budget. */
64
+ export function assertEditableText(text, label = "content") {
65
+ if (Buffer.byteLength(text, "utf8") > MAX_EDITABLE_BYTES) {
66
+ throw limitError(label, `${MAX_EDITABLE_BYTES} UTF-8 bytes`);
67
+ }
68
+ if (text.length > MAX_EDITABLE_CHARS) {
69
+ throw limitError(label, `${MAX_EDITABLE_CHARS} characters`);
70
+ }
71
+ if (lineCount(text) > MAX_EDITABLE_LINES) {
72
+ throw limitError(label, `${MAX_EDITABLE_LINES} lines`);
73
+ }
74
+ }
75
+ /** Check a replacement's size before constructing the resulting string. */
76
+ export function assertReplacementFits(before, oldText, newText, replacements) {
77
+ const projectedChars = before.length + replacements * (newText.length - oldText.length);
78
+ if (projectedChars > MAX_EDITABLE_CHARS) {
79
+ throw limitError("edited content", `${MAX_EDITABLE_CHARS} characters`);
80
+ }
81
+ const projectedLines = lineCount(before) +
82
+ replacements * (newlineCount(newText) - newlineCount(oldText));
83
+ if (projectedLines > MAX_EDITABLE_LINES) {
84
+ throw limitError("edited content", `${MAX_EDITABLE_LINES} lines`);
85
+ }
86
+ }
87
+ function lineCount(text) {
88
+ return newlineCount(text) + 1;
89
+ }
90
+ function newlineCount(text) {
91
+ let lines = 0;
92
+ for (let index = text.indexOf("\n"); index !== -1; index = text.indexOf("\n", index + 1)) {
93
+ lines += 1;
94
+ }
95
+ return lines;
96
+ }
97
+ function limitError(label, limit) {
98
+ return new Error(`${label} exceeds the whole-file mutation limit of ${limit}`);
99
+ }
100
+ function isMissing(error) {
101
+ return error.code === "ENOENT";
102
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ // Write one automatically named transcript to Jecode's application root.
2
+ import { atomicWrite } from "./atomic.js";
3
+ import { assertDirectWritableInRoot, displayPath, resolveDirectWritableInRoot, resolveExistingInRoot, } from "./tools/paths.js";
4
+ import { defaultTranscriptName, transcriptMarkdown } from "./transcript.js";
5
+ export async function saveTranscript(applicationRoot, blocks, now = new Date()) {
6
+ const root = await resolveExistingInRoot(applicationRoot, ".");
7
+ const target = await resolveDirectWritableInRoot(root, defaultTranscriptName(now));
8
+ const validate = () => assertDirectWritableInRoot(root, target);
9
+ await atomicWrite(target, transcriptMarkdown(blocks), { validate });
10
+ return displayPath(root, target);
11
+ }
@@ -0,0 +1,49 @@
1
+ // A portable transcript: screen blocks in, Markdown out.
2
+ import { terminalText } from "./ui/terminal-text.js";
3
+ export function defaultTranscriptName(now = new Date()) {
4
+ return `jecode-transcript-${now.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z")}.md`;
5
+ }
6
+ export function transcriptMarkdown(blocks) {
7
+ const out = ["# jecode transcript", ""];
8
+ for (const block of blocks) {
9
+ switch (block.kind) {
10
+ case "user":
11
+ out.push("## You", "", safeMultiline(block.text), "");
12
+ break;
13
+ case "answer":
14
+ out.push("## Assistant", "", safeMultiline(block.text), "");
15
+ break;
16
+ case "reasoning":
17
+ out.push("<details>", "<summary>Reasoning</summary>", "", safeMultiline(block.text), "", "</details>", "");
18
+ break;
19
+ case "tool":
20
+ out.push(`- **${safeInline(block.name)}**${block.target === "" ? "" : ` \`${inlineCode(block.target)}\``}${block.right === "" ? "" : ` — ${safeInline(block.right)}`}`);
21
+ if ((block.body?.length ?? 0) > 0) {
22
+ out.push("", "````text", ...(block.body ?? []).map(detail), "````", "");
23
+ }
24
+ break;
25
+ case "notice":
26
+ out.push(`> ${block.tone.toUpperCase()}: ${safeMultiline(block.text).replaceAll("\n", "\n> ")}`, "");
27
+ break;
28
+ case "list":
29
+ out.push(...block.items.map((item) => safeInline(item.text)), "");
30
+ break;
31
+ }
32
+ }
33
+ while (out[out.length - 1] === "")
34
+ out.pop();
35
+ return `${out.join("\n")}\n`;
36
+ }
37
+ function detail(row) {
38
+ const prefix = row.kind === "add" ? "+ " : row.kind === "del" ? "- " : row.kind === "keep" ? " " : "";
39
+ return `${prefix}${safeInline(row.text)}`;
40
+ }
41
+ function inlineCode(text) {
42
+ return safeInline(text).replace(/`/g, "ˋ");
43
+ }
44
+ function safeInline(text) {
45
+ return terminalText(text);
46
+ }
47
+ function safeMultiline(text) {
48
+ return terminalText(text, { multiline: true });
49
+ }
@@ -0,0 +1,11 @@
1
+ // One foreground operation owns cancellation and elapsed time.
2
+ export function begin(kind, label, now = Date.now()) {
3
+ return { kind, label, control: new AbortController(), startedAt: now };
4
+ }
5
+ export function elapsed(activity, now = Date.now()) {
6
+ const seconds = Math.max(0, Math.floor((now - activity.startedAt) / 1_000));
7
+ if (seconds < 60)
8
+ return `${seconds}s`;
9
+ const minutes = Math.floor(seconds / 60);
10
+ return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s`;
11
+ }
@@ -0,0 +1,155 @@
1
+ // Keyboard and pointer intent for the TUI shell.
2
+ import { activate as activateCompletion, move as moveCompletion, selected as selectedCompletion, } from "./complete.js";
3
+ import * as edit from "./editor.js";
4
+ import { turnBlocker } from "./feedback.js";
5
+ import { applyKey } from "./input.js";
6
+ import * as overlay from "./overlay.js";
7
+ import { toggleDetails } from "./session-view.js";
8
+ const SCROLL_STEP = 8;
9
+ const WHEEL_STEP = 3;
10
+ export function appInput(options) {
11
+ const { session, state, feedback, actions } = options;
12
+ function handle(key) {
13
+ if (!options.live())
14
+ return;
15
+ // The wheel only changes what is visible. It never answers an open prompt.
16
+ if (key.name === "pointer") {
17
+ const wheel = key.pointer?.wheel;
18
+ if (wheel !== undefined)
19
+ options.scrollBy(wheel === "up" ? WHEEL_STEP : -WHEEL_STEP);
20
+ return;
21
+ }
22
+ if (state.feedback !== undefined)
23
+ feedback.dismiss();
24
+ if (state.open !== undefined) {
25
+ const outcome = overlay.handle(state.open, key);
26
+ state.open = outcome.open;
27
+ if (outcome.abort === true)
28
+ state.activity?.control.abort(new Error("interrupted"));
29
+ if (outcome.quit === true)
30
+ options.requestQuit();
31
+ return;
32
+ }
33
+ if (key.ctrl && key.name === "c") {
34
+ if (state.activity !== undefined)
35
+ state.activity.control.abort(new Error("interrupted"));
36
+ else
37
+ options.quit();
38
+ return;
39
+ }
40
+ if (key.ctrl && key.name === "d" && state.editor.text === "") {
41
+ options.requestQuit();
42
+ return;
43
+ }
44
+ switch (key.name) {
45
+ case "escape":
46
+ if (state.completing !== undefined) {
47
+ state.completing = undefined;
48
+ return;
49
+ }
50
+ state.activity?.control.abort(new Error("interrupted"));
51
+ return;
52
+ case "enter": {
53
+ if (state.completing !== undefined) {
54
+ const completed = selectedCompletion(state.completing);
55
+ if (completed !== undefined)
56
+ state.editor = edit.of(completed);
57
+ state.completing = undefined;
58
+ }
59
+ submit();
60
+ return;
61
+ }
62
+ case "tab": {
63
+ const completion = state.completing ?? activateCompletion(state.editor.text);
64
+ const completed = completion === undefined ? undefined : selectedCompletion(completion);
65
+ if (completed !== undefined) {
66
+ state.editor = edit.of(completed);
67
+ state.completing = undefined;
68
+ }
69
+ return;
70
+ }
71
+ case "up":
72
+ if (state.completing !== undefined) {
73
+ state.completing = moveCompletion(state.completing, -1);
74
+ return;
75
+ }
76
+ recall(-1);
77
+ return;
78
+ case "down":
79
+ if (state.completing !== undefined) {
80
+ state.completing = moveCompletion(state.completing, 1);
81
+ return;
82
+ }
83
+ recall(1);
84
+ return;
85
+ case "pageup":
86
+ options.scrollBy(SCROLL_STEP);
87
+ return;
88
+ case "pagedown":
89
+ options.scrollBy(-SCROLL_STEP);
90
+ return;
91
+ default:
92
+ break;
93
+ }
94
+ if (key.ctrl && key.name === "l") {
95
+ options.invalidate();
96
+ return;
97
+ }
98
+ if (key.ctrl && key.name === "o") {
99
+ const changed = toggleDetails(state.blocks);
100
+ if (changed !== undefined)
101
+ options.transcriptChanged(changed);
102
+ return;
103
+ }
104
+ const edited = applyKey(state.editor, key);
105
+ if (edited !== undefined) {
106
+ state.editor = edited;
107
+ state.completing = activateCompletion(edited.text);
108
+ }
109
+ }
110
+ function recall(step) {
111
+ if (state.past.length === 0)
112
+ return;
113
+ state.completing = undefined;
114
+ if (state.recall === -1) {
115
+ if (step > 0)
116
+ return;
117
+ state.draft = state.editor.text;
118
+ state.recall = state.past.length;
119
+ }
120
+ const next = state.recall + step;
121
+ if (next >= state.past.length) {
122
+ state.recall = -1;
123
+ state.editor = edit.of(state.draft);
124
+ return;
125
+ }
126
+ state.recall = Math.max(0, next);
127
+ state.editor = edit.of(state.past[state.recall]);
128
+ }
129
+ function submit() {
130
+ const text = state.editor.text.trim();
131
+ if (text === "" || state.activity !== undefined)
132
+ return;
133
+ const isCommand = text.startsWith("/");
134
+ if (!isCommand) {
135
+ const blocker = turnBlocker(session);
136
+ if (blocker !== undefined) {
137
+ feedback.show(blocker);
138
+ return;
139
+ }
140
+ }
141
+ state.editor = edit.EMPTY;
142
+ state.recall = -1;
143
+ state.draft = "";
144
+ state.completing = undefined;
145
+ state.past.push(text);
146
+ state.scroll = 0;
147
+ state.follow = true;
148
+ state.unseen = 0;
149
+ if (isCommand)
150
+ void actions.command(text);
151
+ else
152
+ void actions.turn(text);
153
+ }
154
+ return { handle };
155
+ }
@@ -0,0 +1,17 @@
1
+ // Mutable screen state shared by the shell, input, and foreground workflows.
2
+ import * as edit from "./editor.js";
3
+ export function appState() {
4
+ return {
5
+ blocks: [],
6
+ editor: edit.EMPTY,
7
+ scroll: 0,
8
+ follow: true,
9
+ unseen: 0,
10
+ lastMaxScroll: 0,
11
+ past: [],
12
+ recall: -1,
13
+ draft: "",
14
+ spin: 0,
15
+ closeWhenIdle: false,
16
+ };
17
+ }
@@ -0,0 +1,105 @@
1
+ // Foreground command and model-turn workflows for the TUI shell.
2
+ import { handleCommand } from "../commands.js";
3
+ import { runTurn } from "../controller.js";
4
+ import { updateSettings } from "../settings.js";
5
+ import { saveTranscript } from "../transcript-export.js";
6
+ import { recordUsage } from "../usage.js";
7
+ import { answerAt, scopeFor } from "./approve.js";
8
+ import { controllerOptions, turnFailure } from "./session-view.js";
9
+ import { transcribe } from "./turn.js";
10
+ const WAITING = "Waiting";
11
+ export function appWorkflows(options) {
12
+ const { session, state, allowed, feedback } = options;
13
+ async function command(text) {
14
+ const activity = options.startActivity("command", `Running ${text.split(/\s+/)[0]}`);
15
+ if (activity === undefined)
16
+ return;
17
+ try {
18
+ const outcome = await handleCommand(text, session, {
19
+ emit: options.commandOutput,
20
+ signal: activity.control.signal,
21
+ choose: (picker) => new Promise((resolve) => {
22
+ state.open = { picker, settle: resolve };
23
+ options.render();
24
+ }),
25
+ type: (field) => new Promise((resolve) => {
26
+ state.open = { field, settle: resolve };
27
+ options.render();
28
+ }),
29
+ status: (said) => {
30
+ state.status = said ?? activity.label;
31
+ options.render();
32
+ },
33
+ reset: () => {
34
+ state.blocks.splice(0);
35
+ state.past.length = 0;
36
+ allowed.clear();
37
+ state.scroll = 0;
38
+ state.follow = true;
39
+ state.unseen = 0;
40
+ state.lastMaxScroll = 0;
41
+ },
42
+ permissions: () => [...allowed].map(([key, label]) => ({ key, label })),
43
+ revokePermission: (key) => {
44
+ if (key === undefined)
45
+ allowed.clear();
46
+ else
47
+ allowed.delete(key);
48
+ },
49
+ exportTranscript: () => saveTranscript(options.transcriptRoot, state.blocks),
50
+ saveSettings: async (patch) => {
51
+ await updateSettings(patch);
52
+ },
53
+ refreshSettings: options.refreshSettings,
54
+ });
55
+ if (outcome === "exit")
56
+ state.closeWhenIdle = true;
57
+ }
58
+ catch (error) {
59
+ feedback.show({
60
+ text: activity.control.signal.aborted ? "interrupted" : error.message,
61
+ tone: activity.control.signal.aborted ? "warn" : "error",
62
+ timeoutMs: activity.control.signal.aborted ? 4_200 : 6_000,
63
+ });
64
+ }
65
+ finally {
66
+ options.finishActivity(activity);
67
+ }
68
+ }
69
+ async function turn(text) {
70
+ const activity = options.startActivity("turn", WAITING);
71
+ if (activity === undefined)
72
+ return;
73
+ options.emit({ kind: "user", text });
74
+ session.history.push({ role: "user", content: [{ kind: "text", text }] });
75
+ const events = transcribe({
76
+ emit: options.emit,
77
+ render: options.render,
78
+ palette: session.palette,
79
+ approved: (call) => session.config.autoApprove || allowed.has(scopeFor(call).key),
80
+ remember: (call) => {
81
+ const scope = scopeFor(call);
82
+ allowed.set(scope.key, scope.summary);
83
+ },
84
+ ask: (prompt, settle) => {
85
+ state.open = { picker: prompt, settle: (index) => settle(answerAt(index)) };
86
+ options.render();
87
+ },
88
+ status: (text) => {
89
+ state.status = text;
90
+ },
91
+ usage: (usage) => recordUsage(session.usage, usage),
92
+ });
93
+ try {
94
+ await runTurn(session.history, controllerOptions(session), events, activity.control.signal);
95
+ }
96
+ catch (error) {
97
+ options.emit(turnFailure(session, error, activity.control.signal.aborted));
98
+ }
99
+ finally {
100
+ events.finish();
101
+ options.finishActivity(activity);
102
+ }
103
+ }
104
+ return { command, turn };
105
+ }