tinker-agent 2.9.0 → 2.10.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.
- package/CHANGELOG.md +41 -1
- package/README.md +17 -1
- package/package.json +2 -1
- package/src/agent/runtime-hosted-session.ts +443 -0
- package/src/cli/command-line.ts +26 -2
- package/src/cli/connect-runner.tsx +26 -0
- package/src/cli/main.ts +26 -0
- package/src/cli/output.ts +1 -1
- package/src/cli/public-cli-contract.ts +18 -0
- package/src/cli/public-config-contract.ts +1 -1
- package/src/cli/serve-runner.ts +45 -0
- package/src/cli/serve-runtime.ts +100 -0
- package/src/context/context-swap-renderer.ts +14 -0
- package/src/observation/observation-builder.ts +87 -37
- package/src/remote/client.ts +350 -0
- package/src/remote/config.ts +95 -0
- package/src/remote/http-server.ts +240 -0
- package/src/remote/protocol.ts +228 -0
- package/src/remote/service-store.ts +175 -0
- package/src/remote/service.ts +219 -0
- package/src/remote/sync-hub.ts +95 -0
- package/src/session/remote-history-reader.ts +143 -0
- package/src/tools/bash-task.ts +26 -16
- package/src/tools/bash.ts +44 -2
- package/src/tools/glob.ts +107 -19
- package/src/tools/grep-output.ts +130 -0
- package/src/tools/grep-pagination.ts +73 -0
- package/src/tools/grep-path.ts +11 -0
- package/src/tools/grep-snippets.ts +111 -0
- package/src/tools/grep.ts +139 -154
- package/src/tools/read.ts +0 -9
- package/src/tools/ripgrep.ts +19 -26
- package/src/tools/shell-process.ts +30 -4
- package/src/tools/task-stop.ts +2 -1
- package/src/tools/terminal-screen.ts +11 -2
- package/src/tools/types.ts +30 -2
- package/src/tui/event-store.ts +15 -2
- package/src/tui/remote-app.tsx +210 -0
|
@@ -14,6 +14,7 @@ export type ShellProcessHandle = {
|
|
|
14
14
|
readonly mode: ShellProcessMode;
|
|
15
15
|
readonly exitCode: number | null;
|
|
16
16
|
readonly signalCode: NodeJS.Signals | null;
|
|
17
|
+
readonly outputClosed: boolean;
|
|
17
18
|
wait(): Promise<ProcessExitResult>;
|
|
18
19
|
waitForOutputClose(): Promise<void>;
|
|
19
20
|
write?(chars: string): Promise<number>;
|
|
@@ -35,6 +36,8 @@ export async function spawnShellProcess(input: {
|
|
|
35
36
|
command: string;
|
|
36
37
|
cwd: string;
|
|
37
38
|
cwdFilePath: string;
|
|
39
|
+
cols?: number;
|
|
40
|
+
rows?: number;
|
|
38
41
|
onOutput(bytes: Uint8Array): void;
|
|
39
42
|
}): Promise<ShellProcessHandle> {
|
|
40
43
|
const env = {
|
|
@@ -65,7 +68,16 @@ async function spawnPipeShellProcess(input: {
|
|
|
65
68
|
pipeOutput(child.stderr, (bytes) => input.onOutput(bytes));
|
|
66
69
|
|
|
67
70
|
const exit = waitForNodeProcessExit(child);
|
|
68
|
-
|
|
71
|
+
let outputClosed = false;
|
|
72
|
+
let resolveOutputClose: () => void;
|
|
73
|
+
const close = new Promise<void>((resolve) => {
|
|
74
|
+
resolveOutputClose = resolve;
|
|
75
|
+
});
|
|
76
|
+
const settleOutputClose = () => {
|
|
77
|
+
outputClosed = true;
|
|
78
|
+
resolveOutputClose();
|
|
79
|
+
};
|
|
80
|
+
void waitForNodeProcessClose(child).then(settleOutputClose);
|
|
69
81
|
if (child.pid === undefined) {
|
|
70
82
|
const result = await exit;
|
|
71
83
|
throw new Error(result.error ?? "Bash process failed to start.");
|
|
@@ -80,15 +92,25 @@ async function spawnPipeShellProcess(input: {
|
|
|
80
92
|
get signalCode() {
|
|
81
93
|
return child.signalCode;
|
|
82
94
|
},
|
|
95
|
+
get outputClosed() {
|
|
96
|
+
return outputClosed;
|
|
97
|
+
},
|
|
83
98
|
wait: () => exit,
|
|
84
99
|
waitForOutputClose: () => close,
|
|
85
|
-
close() {
|
|
100
|
+
close() {
|
|
101
|
+
child.stdin.destroy();
|
|
102
|
+
child.stdout.destroy();
|
|
103
|
+
child.stderr.destroy();
|
|
104
|
+
settleOutputClose();
|
|
105
|
+
},
|
|
86
106
|
};
|
|
87
107
|
}
|
|
88
108
|
|
|
89
109
|
function spawnPtyShellProcess(input: {
|
|
90
110
|
cwd: string;
|
|
91
111
|
env: NodeJS.ProcessEnv;
|
|
112
|
+
cols?: number;
|
|
113
|
+
rows?: number;
|
|
92
114
|
onOutput(bytes: Uint8Array): void;
|
|
93
115
|
}): ShellProcessHandle {
|
|
94
116
|
let terminalEnded = false;
|
|
@@ -125,8 +147,8 @@ function spawnPtyShellProcess(input: {
|
|
|
125
147
|
GIT_PAGER: "cat",
|
|
126
148
|
},
|
|
127
149
|
terminal: {
|
|
128
|
-
cols: TERMINAL_SCREEN_COLUMNS,
|
|
129
|
-
rows: TERMINAL_SCREEN_ROWS,
|
|
150
|
+
cols: input.cols ?? TERMINAL_SCREEN_COLUMNS,
|
|
151
|
+
rows: input.rows ?? TERMINAL_SCREEN_ROWS,
|
|
130
152
|
name: "xterm-256color",
|
|
131
153
|
data(_terminal, bytes) {
|
|
132
154
|
input.onOutput(new Uint8Array(bytes));
|
|
@@ -205,6 +227,9 @@ function spawnPtyShellProcess(input: {
|
|
|
205
227
|
get signalCode() {
|
|
206
228
|
return subprocess.signalCode;
|
|
207
229
|
},
|
|
230
|
+
get outputClosed() {
|
|
231
|
+
return terminalEnded;
|
|
232
|
+
},
|
|
208
233
|
wait: () => exit,
|
|
209
234
|
waitForOutputClose: () => terminalExit,
|
|
210
235
|
write,
|
|
@@ -212,6 +237,7 @@ function spawnPtyShellProcess(input: {
|
|
|
212
237
|
if (!terminal.closed) {
|
|
213
238
|
terminal.close();
|
|
214
239
|
}
|
|
240
|
+
settleTerminalExit();
|
|
215
241
|
},
|
|
216
242
|
};
|
|
217
243
|
}
|
package/src/tools/task-stop.ts
CHANGED
|
@@ -37,12 +37,13 @@ export function createTaskStopToolExecutor(options: {
|
|
|
37
37
|
try {
|
|
38
38
|
const result = await options.taskManager.stopTask(parsed.taskId, "tool");
|
|
39
39
|
return {
|
|
40
|
-
ok:
|
|
40
|
+
ok: result.task.error === undefined,
|
|
41
41
|
taskId: parsed.taskId,
|
|
42
42
|
task: result.task,
|
|
43
43
|
status: result.task.status,
|
|
44
44
|
requestedSignal: result.requestedSignal,
|
|
45
45
|
escalated: result.escalated,
|
|
46
|
+
...(result.task.error === undefined ? {} : { error: result.task.error }),
|
|
46
47
|
};
|
|
47
48
|
} catch (error) {
|
|
48
49
|
const inspection = options.taskManager.inspectTask(parsed.taskId);
|
|
@@ -3,16 +3,25 @@ import { Terminal } from "@xterm/headless";
|
|
|
3
3
|
|
|
4
4
|
export const TERMINAL_SCREEN_ROWS = 24;
|
|
5
5
|
export const TERMINAL_SCREEN_COLUMNS = 80;
|
|
6
|
+
export const MIN_TERMINAL_COLUMNS = 2;
|
|
7
|
+
export const MAX_TERMINAL_DIMENSION = 1_000;
|
|
6
8
|
|
|
7
9
|
export type TerminalScreen = {
|
|
10
|
+
readonly rows: number;
|
|
11
|
+
readonly columns: number;
|
|
8
12
|
write(bytes: Uint8Array): Promise<void>;
|
|
9
13
|
flush(): Promise<void>;
|
|
10
14
|
text(): string;
|
|
11
15
|
dispose(): void;
|
|
12
16
|
};
|
|
13
17
|
|
|
14
|
-
export function createTerminalScreen(
|
|
15
|
-
|
|
18
|
+
export function createTerminalScreen(
|
|
19
|
+
options: { cols?: number; rows?: number } = {},
|
|
20
|
+
): TerminalScreen {
|
|
21
|
+
return new HeadlessTerminalScreen(
|
|
22
|
+
options.rows ?? TERMINAL_SCREEN_ROWS,
|
|
23
|
+
options.cols ?? TERMINAL_SCREEN_COLUMNS,
|
|
24
|
+
);
|
|
16
25
|
}
|
|
17
26
|
|
|
18
27
|
export class HeadlessTerminalScreen implements TerminalScreen {
|
package/src/tools/types.ts
CHANGED
|
@@ -92,16 +92,27 @@ export type DeleteFileRawResult = {
|
|
|
92
92
|
|
|
93
93
|
export type GlobRawResult = {
|
|
94
94
|
ok: boolean;
|
|
95
|
-
pattern
|
|
95
|
+
pattern?: string;
|
|
96
96
|
searchPath: string;
|
|
97
97
|
absoluteSearchPath?: string;
|
|
98
98
|
matches?: string[];
|
|
99
|
+
/** Number of paths returned, including in legacy unpaginated results. */
|
|
99
100
|
matchCount?: number;
|
|
101
|
+
/** Exact total before pagination; absent in legacy results. */
|
|
102
|
+
totalMatches?: number;
|
|
103
|
+
returnedCount?: number;
|
|
104
|
+
appliedOffset?: number;
|
|
105
|
+
hasMore?: boolean;
|
|
106
|
+
nextOffset?: number;
|
|
100
107
|
ignored?: string[];
|
|
101
108
|
error?: string;
|
|
102
109
|
};
|
|
103
110
|
|
|
104
|
-
export type GrepOutputMode =
|
|
111
|
+
export type GrepOutputMode =
|
|
112
|
+
| "content"
|
|
113
|
+
| "files_with_matches"
|
|
114
|
+
| "count"
|
|
115
|
+
| "count-matches";
|
|
105
116
|
|
|
106
117
|
export type GrepRawResult = {
|
|
107
118
|
ok: boolean;
|
|
@@ -113,10 +124,27 @@ export type GrepRawResult = {
|
|
|
113
124
|
numFiles: number;
|
|
114
125
|
content?: string;
|
|
115
126
|
numLines?: number;
|
|
127
|
+
/** Structured count records; paths are unescaped and never parsed from display text. */
|
|
128
|
+
counts?: { filePath: string; count: number }[];
|
|
129
|
+
/** Sum on this page: matching lines for count, individual matches for count-matches. */
|
|
116
130
|
numMatches?: number;
|
|
131
|
+
/** Collected pagination units; legacy content results counted all output lines. Not a global total if searchIncomplete. */
|
|
132
|
+
totalResults?: number;
|
|
133
|
+
/** Selected pagination units, excluding context and nearby matches shown as context. */
|
|
134
|
+
returnedResults?: number;
|
|
135
|
+
paginationUnit?: "matching_lines" | "match_events" | "files";
|
|
136
|
+
/** More collected pagination units are available, independently of search completeness. */
|
|
137
|
+
hasMore?: boolean;
|
|
138
|
+
nextOffset?: number;
|
|
117
139
|
appliedLimit?: number;
|
|
118
140
|
appliedOffset?: number;
|
|
141
|
+
/** True for interrupted searches (e.g. timeout or output buffer limit), not pagination. */
|
|
142
|
+
searchIncomplete?: boolean;
|
|
143
|
+
/** The search stopped early, so requested context cannot be guaranteed complete. */
|
|
144
|
+
contextMayBeIncomplete?: boolean;
|
|
145
|
+
/** Legacy default exclusions, not observed skips. New Grep results omit this field. */
|
|
119
146
|
ignored?: string[];
|
|
147
|
+
/** Compatibility flag combining pagination and interrupted search; prefer explicit fields above. */
|
|
120
148
|
truncated?: boolean;
|
|
121
149
|
error?: string;
|
|
122
150
|
};
|
package/src/tui/event-store.ts
CHANGED
|
@@ -873,6 +873,9 @@ function toolRawResultSummary(name: string, args: unknown, raw: ToolRawResult):
|
|
|
873
873
|
? base
|
|
874
874
|
: `${base} -> ${raw.asset.mimeType}, ${raw.asset.width}x${raw.asset.height}, ${raw.asset.byteLength} bytes`;
|
|
875
875
|
case "glob":
|
|
876
|
+
if (raw.ok && raw.totalMatches !== undefined) {
|
|
877
|
+
return `${base} -> ${raw.returnedCount ?? raw.matches?.length ?? 0} of ${raw.totalMatches} matches`;
|
|
878
|
+
}
|
|
876
879
|
return raw.ok && raw.matchCount !== undefined
|
|
877
880
|
? `${base} -> ${raw.matchCount} match${raw.matchCount === 1 ? "" : "es"}`
|
|
878
881
|
: base;
|
|
@@ -881,11 +884,21 @@ function toolRawResultSummary(name: string, args: unknown, raw: ToolRawResult):
|
|
|
881
884
|
return `${base} -> ${raw.numLines} line${raw.numLines === 1 ? "" : "s"}`;
|
|
882
885
|
}
|
|
883
886
|
if (
|
|
884
|
-
raw.mode === "count" &&
|
|
887
|
+
(raw.mode === "count" || raw.mode === "count-matches") &&
|
|
885
888
|
raw.numMatches !== undefined &&
|
|
886
889
|
raw.numFiles !== undefined
|
|
887
890
|
) {
|
|
888
|
-
|
|
891
|
+
const unit =
|
|
892
|
+
raw.mode === "count"
|
|
893
|
+
? `matching line${raw.numMatches === 1 ? "" : "s"}`
|
|
894
|
+
: `match${raw.numMatches === 1 ? "" : "es"}`;
|
|
895
|
+
const scope =
|
|
896
|
+
raw.appliedLimit !== undefined || (raw.appliedOffset ?? 0) > 0
|
|
897
|
+
? " (this page)"
|
|
898
|
+
: raw.truncated
|
|
899
|
+
? " (partial results)"
|
|
900
|
+
: "";
|
|
901
|
+
return `${base} -> ${raw.numMatches} ${unit} across ${raw.numFiles} file${raw.numFiles === 1 ? "" : "s"}${scope}`;
|
|
889
902
|
}
|
|
890
903
|
return `${base} -> ${raw.numFiles} file${raw.numFiles === 1 ? "" : "s"}`;
|
|
891
904
|
case "read":
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { Box, Text, useApp, useInput } from "ink";
|
|
2
|
+
import { TextInput } from "@inkjs/ui";
|
|
3
|
+
import { useEffect, useState, useSyncExternalStore } from "react";
|
|
4
|
+
import { RemoteClient } from "../remote/client";
|
|
5
|
+
import type { RemoteSessionInfo } from "../remote/protocol";
|
|
6
|
+
|
|
7
|
+
/** An explicit network client mode; the normal App/controller path is untouched. */
|
|
8
|
+
export function RemoteApp({ client }: { client: RemoteClient }) {
|
|
9
|
+
const state = useSyncExternalStore(client.subscribe, client.getSnapshot);
|
|
10
|
+
const { exit } = useApp();
|
|
11
|
+
const [workspaces, setWorkspaces] = useState<{ id: string; name: string }[]>([]);
|
|
12
|
+
const [sessions, setSessions] = useState<RemoteSessionInfo[]>([]);
|
|
13
|
+
const [workspace, setWorkspace] = useState<string | undefined>(client.workspaceId);
|
|
14
|
+
const [notice, setNotice] = useState(
|
|
15
|
+
"Select a workspace number. /workspaces returns here; /quit detaches.",
|
|
16
|
+
);
|
|
17
|
+
const [inputKey, setInputKey] = useState(0);
|
|
18
|
+
const [visibleCount, setVisibleCount] = useState(30);
|
|
19
|
+
const [browsing, setBrowsing] = useState(!client.sessionId);
|
|
20
|
+
const report = (error: unknown) =>
|
|
21
|
+
setNotice(error instanceof Error ? error.message : String(error));
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
void client
|
|
24
|
+
.workspaces()
|
|
25
|
+
.then((result) => setWorkspaces(result.workspaces))
|
|
26
|
+
.catch(report);
|
|
27
|
+
}, [client]);
|
|
28
|
+
useInput((input, key) => {
|
|
29
|
+
if (key.ctrl && input === "c") exit();
|
|
30
|
+
});
|
|
31
|
+
const submit = async (text: string) => {
|
|
32
|
+
setInputKey((value) => value + 1);
|
|
33
|
+
if (!text.trim()) return;
|
|
34
|
+
if (text === "/quit") {
|
|
35
|
+
exit();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (text === "/workspaces") {
|
|
39
|
+
setWorkspace(undefined);
|
|
40
|
+
setBrowsing(true);
|
|
41
|
+
setSessions([]);
|
|
42
|
+
setWorkspaces((await client.workspaces()).workspaces);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (browsing) {
|
|
46
|
+
if (!workspace) {
|
|
47
|
+
const chosen = workspaces[Number(text) - 1];
|
|
48
|
+
if (!chosen) throw new Error("Enter a workspace number.");
|
|
49
|
+
setWorkspace(chosen.id);
|
|
50
|
+
setSessions((await client.sessions(chosen.id)).sessions);
|
|
51
|
+
setNotice(
|
|
52
|
+
"Enter a session number, or /new. A local session is explicitly attached when selected.",
|
|
53
|
+
);
|
|
54
|
+
} else if (text === "/new") {
|
|
55
|
+
await client.submit({ kind: "create", workspaceId: workspace });
|
|
56
|
+
setBrowsing(false);
|
|
57
|
+
} else {
|
|
58
|
+
const chosen = sessions[Number(text) - 1];
|
|
59
|
+
if (!chosen) throw new Error("Enter a session number or /new.");
|
|
60
|
+
if (chosen.owner === "local")
|
|
61
|
+
await client.submit({
|
|
62
|
+
kind: "adopt",
|
|
63
|
+
workspaceId: workspace,
|
|
64
|
+
sessionId: chosen.id,
|
|
65
|
+
});
|
|
66
|
+
else await client.select(chosen.id, workspace);
|
|
67
|
+
setBrowsing(false);
|
|
68
|
+
}
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const sessionId = client.sessionId;
|
|
72
|
+
if (!sessionId) throw new Error("Waiting for the service to accept the session.");
|
|
73
|
+
const pending = state.view?.interaction;
|
|
74
|
+
if (text === "/stop") {
|
|
75
|
+
if (!state.view?.activeRequestId) throw new Error("No active task is shown.");
|
|
76
|
+
await client.submit({
|
|
77
|
+
kind: "stop",
|
|
78
|
+
sessionId,
|
|
79
|
+
targetRequestId: state.view.activeRequestId,
|
|
80
|
+
});
|
|
81
|
+
} else if (text === "/allow" || text === "/deny") {
|
|
82
|
+
if (pending?.kind !== "confirmation")
|
|
83
|
+
throw new Error("No confirmation is pending.");
|
|
84
|
+
await client.submit({
|
|
85
|
+
kind: "confirm",
|
|
86
|
+
sessionId,
|
|
87
|
+
interactionId: pending.id,
|
|
88
|
+
decision: text === "/allow" ? "allow" : "deny",
|
|
89
|
+
});
|
|
90
|
+
} else if (text.startsWith("/answer ") || text === "/dismiss") {
|
|
91
|
+
if (pending?.kind !== "question") throw new Error("No question is pending.");
|
|
92
|
+
await client.submit({
|
|
93
|
+
kind: "answer",
|
|
94
|
+
sessionId,
|
|
95
|
+
interactionId: pending.id,
|
|
96
|
+
selectedIndex: text === "/dismiss" ? null : Number(text.slice(8)) - 1,
|
|
97
|
+
});
|
|
98
|
+
} else if (text === "/history") {
|
|
99
|
+
await client.loadOlderHistory();
|
|
100
|
+
setVisibleCount((count) => count + 80);
|
|
101
|
+
setNotice(
|
|
102
|
+
"Older history loaded; use your terminal scrollback to read previous renders.",
|
|
103
|
+
);
|
|
104
|
+
} else if (text.startsWith("/")) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
"Commands: /stop /allow /deny /answer N /dismiss /history /workspaces /quit",
|
|
107
|
+
);
|
|
108
|
+
} else {
|
|
109
|
+
await client.submit({ kind: "prompt", sessionId, prompt: text });
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
const view = state.view;
|
|
113
|
+
return (
|
|
114
|
+
<Box flexDirection="column">
|
|
115
|
+
<Text bold color="cyan">
|
|
116
|
+
Tinker · Service connection
|
|
117
|
+
</Text>
|
|
118
|
+
<Text>
|
|
119
|
+
{state.connection} · {view?.status ?? "select session"} · {state.pending}{" "}
|
|
120
|
+
unsubmitted
|
|
121
|
+
</Text>
|
|
122
|
+
<Text dimColor>{notice}</Text>
|
|
123
|
+
{state.error && <Text color="red">{safe(state.error)}</Text>}
|
|
124
|
+
{browsing ? (
|
|
125
|
+
<Box flexDirection="column">
|
|
126
|
+
{!workspace
|
|
127
|
+
? workspaces.map((w, i) => (
|
|
128
|
+
<Text key={w.id}>
|
|
129
|
+
{i + 1}. {safe(w.name)}
|
|
130
|
+
</Text>
|
|
131
|
+
))
|
|
132
|
+
: sessions.map((s, i) => (
|
|
133
|
+
<Text key={s.id}>
|
|
134
|
+
{i + 1}. {safe(s.title)} · {s.status} · {s.owner}
|
|
135
|
+
</Text>
|
|
136
|
+
))}
|
|
137
|
+
</Box>
|
|
138
|
+
) : (
|
|
139
|
+
<Box flexDirection="column">
|
|
140
|
+
<Text bold>{safe(view?.session.title ?? "Connecting…")}</Text>
|
|
141
|
+
{view?.history.messages.slice(-visibleCount).map((message) => (
|
|
142
|
+
<Box key={message.id} flexDirection="column" marginTop={1}>
|
|
143
|
+
<Text bold color={message.role === "user" ? "green" : "cyan"}>
|
|
144
|
+
{message.name ?? message.role}
|
|
145
|
+
</Text>
|
|
146
|
+
<Text>{safe(message.text)}</Text>
|
|
147
|
+
{message.toolCalls?.map((call) => (
|
|
148
|
+
<Text key={call.id} dimColor>
|
|
149
|
+
{call.name} {safe(call.arguments)}
|
|
150
|
+
</Text>
|
|
151
|
+
))}
|
|
152
|
+
</Box>
|
|
153
|
+
))}
|
|
154
|
+
{view?.streaming && (
|
|
155
|
+
<Box flexDirection="column">
|
|
156
|
+
<Text dimColor>Generating…</Text>
|
|
157
|
+
<Text>{safe(view.streaming.text)}</Text>
|
|
158
|
+
</Box>
|
|
159
|
+
)}
|
|
160
|
+
{view?.tools
|
|
161
|
+
.filter((tool) => tool.status === "running")
|
|
162
|
+
.map((tool) => (
|
|
163
|
+
<Text key={tool.id} color="yellow">
|
|
164
|
+
{tool.name}: {safe(tool.arguments)}
|
|
165
|
+
</Text>
|
|
166
|
+
))}
|
|
167
|
+
{view?.interaction?.kind === "question" && (
|
|
168
|
+
<Box flexDirection="column">
|
|
169
|
+
<Text color="yellow">{safe(view.interaction.question)}</Text>
|
|
170
|
+
{view.interaction.options.map((option, i) => (
|
|
171
|
+
<Text key={i}>
|
|
172
|
+
{i + 1}. {safe(option.description)}
|
|
173
|
+
</Text>
|
|
174
|
+
))}
|
|
175
|
+
<Text>/answer N or /dismiss</Text>
|
|
176
|
+
</Box>
|
|
177
|
+
)}
|
|
178
|
+
{view?.interaction?.kind === "confirmation" && (
|
|
179
|
+
<Box flexDirection="column">
|
|
180
|
+
<Text color="yellow">{safe(view.interaction.command)}</Text>
|
|
181
|
+
<Text>{safe(view.interaction.reason)} · /allow or /deny</Text>
|
|
182
|
+
</Box>
|
|
183
|
+
)}
|
|
184
|
+
</Box>
|
|
185
|
+
)}
|
|
186
|
+
<Box marginTop={1}>
|
|
187
|
+
<Text color="cyan">› </Text>
|
|
188
|
+
<TextInput
|
|
189
|
+
key={inputKey}
|
|
190
|
+
placeholder={
|
|
191
|
+
browsing
|
|
192
|
+
? "Choose a workspace/session"
|
|
193
|
+
: "Send task; /stop cancels; /quit detaches"
|
|
194
|
+
}
|
|
195
|
+
onSubmit={(value) => {
|
|
196
|
+
void submit(value).catch(report);
|
|
197
|
+
}}
|
|
198
|
+
/>
|
|
199
|
+
</Box>
|
|
200
|
+
</Box>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
function safe(value: string): string {
|
|
204
|
+
return [...value]
|
|
205
|
+
.filter((character) => {
|
|
206
|
+
const code = character.codePointAt(0)!;
|
|
207
|
+
return code === 10 || code === 9 || (code >= 32 && (code < 127 || code > 159));
|
|
208
|
+
})
|
|
209
|
+
.join("");
|
|
210
|
+
}
|