loom-agent 1.2.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/.env.example +25 -0
- package/CHANGELOG.md +402 -0
- package/LICENSE +21 -0
- package/LOOM.md +235 -0
- package/README.md +433 -0
- package/bin/loom-tui.js +43 -0
- package/bin/loom.js +44 -0
- package/docs/acp.md +151 -0
- package/docs/web.md +205 -0
- package/package.json +97 -0
- package/scripts/acp-smoke.js +146 -0
- package/src/acp/acp-server.js +287 -0
- package/src/config/provider-cmd.js +37 -0
- package/src/config/settings.js +164 -0
- package/src/core/agents.js +361 -0
- package/src/core/background-tasks.js +103 -0
- package/src/core/cli.js +579 -0
- package/src/core/custom-commands.js +70 -0
- package/src/core/errors.js +29 -0
- package/src/core/events.js +24 -0
- package/src/core/file-diffs.js +282 -0
- package/src/core/format.js +206 -0
- package/src/core/graph.js +257 -0
- package/src/core/hooks.js +82 -0
- package/src/core/lsp.js +385 -0
- package/src/core/memory.js +87 -0
- package/src/core/model-router.js +87 -0
- package/src/core/permissions.js +327 -0
- package/src/core/platform.js +33 -0
- package/src/core/plugin-cmd.js +380 -0
- package/src/core/restore.js +207 -0
- package/src/core/session-store.js +167 -0
- package/src/core/session.js +910 -0
- package/src/core/subagent-log.js +134 -0
- package/src/core/tokens.js +31 -0
- package/src/core/update.js +6 -0
- package/src/core/usage.js +166 -0
- package/src/index.js +41 -0
- package/src/mcp/mcp-client.js +201 -0
- package/src/mcp/mcp-manager.js +193 -0
- package/src/providers/anthropic.js +243 -0
- package/src/providers/google.js +29 -0
- package/src/providers/index.js +175 -0
- package/src/providers/local.js +27 -0
- package/src/providers/nvidia.js +85 -0
- package/src/providers/openai-compat.js +269 -0
- package/src/providers/openai.js +35 -0
- package/src/providers/openrouter.js +43 -0
- package/src/providers/registry.js +196 -0
- package/src/providers/tokenrouter.js +19 -0
- package/src/skills/skill-matcher.js +133 -0
- package/src/skills/skills-manager.js +213 -0
- package/src/tools/index.js +543 -0
- package/src/tui/App.tsx +1578 -0
- package/src/tui/components/BreadcrumbBar.tsx +34 -0
- package/src/tui/components/ChatArea.tsx +518 -0
- package/src/tui/components/InputBar.tsx +354 -0
- package/src/tui/components/MdText.tsx +105 -0
- package/src/tui/components/Modals.tsx +851 -0
- package/src/tui/components/PermissionPopup.tsx +264 -0
- package/src/tui/components/Sidebar.tsx +182 -0
- package/src/tui/components/SplashScreen.tsx +51 -0
- package/src/tui/components/SubagentPanel.tsx +217 -0
- package/src/tui/components/ToastOverlay.tsx +34 -0
- package/src/tui/keybinds.ts +318 -0
- package/src/tui/mcp-presets.ts +189 -0
- package/src/tui/md-render.ts +228 -0
- package/src/tui/store.ts +714 -0
- package/src/tui/suite-home.ts +20 -0
- package/src/tui/theme.ts +313 -0
- package/src/tui/themes.generated.ts +968 -0
- package/src/tui/tool-display.ts +176 -0
- package/src/tui/toolname.ts +60 -0
- package/src/tui/tui-config.ts +28 -0
- package/src/tui-open.tsx +51 -0
- package/src/web/attach.js +242 -0
- package/src/web/graph-view.html +262 -0
- package/src/web/index.html +824 -0
- package/src/web/web-server.js +470 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// SubagentPanel — /subagents modal listing every subagent run (active + from
|
|
2
|
+
// disk) with live status, elapsed time, cost, and tool log. Enter opens a
|
|
3
|
+
// detail modal with the full output; c cancels a running subagent.
|
|
4
|
+
import { Show, createSignal, createMemo, onMount, onCleanup } from "solid-js";
|
|
5
|
+
import { useKeyboard } from "@opentui/solid";
|
|
6
|
+
import { palette } from "../theme.ts";
|
|
7
|
+
import { ModalFrame } from "./Modals.tsx";
|
|
8
|
+
import * as kbs from "../keybinds.ts";
|
|
9
|
+
import {
|
|
10
|
+
activeSubagents, subagentHistory, cancelSubagentRun, loadSubagentHistory,
|
|
11
|
+
modal, closeModal, openModal,
|
|
12
|
+
type SubagentEntry,
|
|
13
|
+
} from "../store.ts";
|
|
14
|
+
|
|
15
|
+
const ui = palette("loom");
|
|
16
|
+
|
|
17
|
+
function statusGlyph(s: SubagentEntry["status"]): string {
|
|
18
|
+
if (s === "running") return "\u25CF";
|
|
19
|
+
if (s === "done") return "\u2713";
|
|
20
|
+
if (s === "cancelled") return "\u2298";
|
|
21
|
+
return "\u2717";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function statusColor(s: SubagentEntry["status"]): string {
|
|
25
|
+
if (s === "running") return ui.warning || ui.primary;
|
|
26
|
+
if (s === "done") return ui.success || ui.primary;
|
|
27
|
+
if (s === "cancelled") return ui.fgMuted;
|
|
28
|
+
return "#ff5555";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function fmtCost(c: number): string {
|
|
32
|
+
if (!c || c < 0.0001) return "free";
|
|
33
|
+
if (c < 0.01) return "$" + c.toFixed(4);
|
|
34
|
+
return "$" + c.toFixed(2);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function fmtTokens(inT: number, outT: number): string {
|
|
38
|
+
const sum = inT + outT;
|
|
39
|
+
if (sum < 1000) return String(sum) + " tok";
|
|
40
|
+
if (sum < 1000000) return (sum / 1000).toFixed(1) + "k tok";
|
|
41
|
+
return (sum / 1000000).toFixed(2) + "M tok";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function fmtDuration(ms: number): string {
|
|
45
|
+
if (ms < 1000) return ms + "ms";
|
|
46
|
+
if (ms < 60000) return (ms / 1000).toFixed(1) + "s";
|
|
47
|
+
const m = Math.floor(ms / 60000);
|
|
48
|
+
const s = Math.floor((ms % 60000) / 1000);
|
|
49
|
+
return m + "m " + s + "s";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Live tick — bumps once a second while the panel is mounted so running
|
|
53
|
+
// subagents' elapsed/cost/time fields refresh without manual re-renders.
|
|
54
|
+
function useLiveTick(): () => number {
|
|
55
|
+
const [, setTick] = createSignal(0);
|
|
56
|
+
let id: any = null;
|
|
57
|
+
onMount(() => { id = setInterval(() => setTick(v => v + 1), 1000); });
|
|
58
|
+
onCleanup(() => { if (id) clearInterval(id); });
|
|
59
|
+
return () => Date.now();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function openSubagentDetail(runId: string) {
|
|
63
|
+
openModal({ type: "subagent_detail", runId });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function SubagentPanel() {
|
|
67
|
+
const now = useLiveTick();
|
|
68
|
+
|
|
69
|
+
// Merged list: active first (running ones float up), then history.
|
|
70
|
+
const all = createMemo<SubagentEntry[]>(() => {
|
|
71
|
+
const active = Array.from(activeSubagents().values());
|
|
72
|
+
const seen = new Set(active.map(a => a.runId));
|
|
73
|
+
const hist = subagentHistory().filter(h => !seen.has(h.runId));
|
|
74
|
+
return active.concat(hist).sort((a, b) => b.startTime - a.startTime);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const [sel, setSel] = createSignal(0);
|
|
78
|
+
const [statusMsg, setStatusMsg] = createSignal<string>("");
|
|
79
|
+
|
|
80
|
+
const PAGE = 14;
|
|
81
|
+
const win = createMemo(() => {
|
|
82
|
+
const list = all();
|
|
83
|
+
const total = list.length;
|
|
84
|
+
const idx = Math.min(sel(), Math.max(0, total - 1));
|
|
85
|
+
const start = Math.max(0, Math.min(idx - Math.floor(PAGE / 2), total - PAGE));
|
|
86
|
+
return { start: Math.max(0, start), items: list.slice(start, start + PAGE), total, idx };
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
useKeyboard((key: any) => {
|
|
90
|
+
const ks = kbs.keyString(key);
|
|
91
|
+
if (kbs.is("modal_cancel", ks)) { closeModal(); return; }
|
|
92
|
+
if (kbs.dialogIs("dialog_select_next", ks)) { setSel(i => Math.min(all().length - 1, i + 1)); return; }
|
|
93
|
+
if (kbs.dialogIs("dialog_select_prev", ks)) { setSel(i => Math.max(0, i - 1)); return; }
|
|
94
|
+
if (kbs.dialogIs("dialog_select_home", ks)) { setSel(0); return; }
|
|
95
|
+
if (kbs.dialogIs("dialog_select_end", ks)) { setSel(Math.max(0, all().length - 1)); return; }
|
|
96
|
+
if (kbs.dialogIs("dialog_select_submit", ks)) {
|
|
97
|
+
const list = all();
|
|
98
|
+
const cur = list[sel()];
|
|
99
|
+
if (cur) openSubagentDetail(cur.runId);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (key.name === "r") { loadSubagentHistory({ limit: 200 }); setStatusMsg("history refreshed"); return; }
|
|
103
|
+
if (key.name === "m") {
|
|
104
|
+
// Set the DEFAULT model for the selected agent id ("provider/model-id").
|
|
105
|
+
const cur = all()[sel()];
|
|
106
|
+
if (!cur) return;
|
|
107
|
+
const { loadConfig, saveConfig } = require("../../../config/settings.js");
|
|
108
|
+
const cfg = loadConfig();
|
|
109
|
+
cfg.agents = cfg.agents || {};
|
|
110
|
+
const curModel = (cfg.agents[cur.agentId] && cfg.agents[cur.agentId].model) || "";
|
|
111
|
+
openModal({
|
|
112
|
+
type: "input", title: "Default model for " + cur.agent,
|
|
113
|
+
placeholder: 'e.g. "anthropic/claude-sonnet-4-20250514" — empty clears',
|
|
114
|
+
value: curModel,
|
|
115
|
+
onPick(val: string) {
|
|
116
|
+
closeModal();
|
|
117
|
+
const v = String(val || "").trim();
|
|
118
|
+
if (v) { cfg.agents[cur.agentId] = Object.assign({}, cfg.agents[cur.agentId], { model: v }); saveConfig(cfg); }
|
|
119
|
+
else if (cfg.agents[cur.agentId]) { delete cfg.agents[cur.agentId].model; saveConfig(cfg); }
|
|
120
|
+
setStatusMsg(v ? cur.agentId + " default model \u2192 " + v : cur.agentId + " default model cleared");
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (key.name === "c") {
|
|
126
|
+
const list = all();
|
|
127
|
+
const cur = list[sel()];
|
|
128
|
+
if (cur && cur.status === "running") {
|
|
129
|
+
const ok = cancelSubagentRun(cur.runId);
|
|
130
|
+
setStatusMsg(ok ? "cancelling " + cur.agent + " \u2026" : "cancel failed (already finished)");
|
|
131
|
+
} else {
|
|
132
|
+
setStatusMsg("nothing to cancel (selected is not running)");
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const activeCount = createMemo(() => Array.from(activeSubagents().values()).filter(e => e.status === "running").length);
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<ModalFrame
|
|
142
|
+
title="Subagents"
|
|
143
|
+
subtitle={(activeCount() || 0) + " active \u00B7 " + all().length + " total \u00B7 arrows navigate \u00B7 Enter details \u00B7 c cancel \u00B7 r refresh"}
|
|
144
|
+
footer="Enter details \u00B7 c cancel running \u00B7 r refresh history \u00B7 Esc close"
|
|
145
|
+
>
|
|
146
|
+
<Show when={win().total === 0}>
|
|
147
|
+
<text fg={ui.fgMuted}>No subagent runs yet. Delegate via the task tool or @mention and it will appear here</text>
|
|
148
|
+
</Show>
|
|
149
|
+
<Show when={win().total > 0}>
|
|
150
|
+
<box flexDirection="column">
|
|
151
|
+
{win().items.map((entry: any, i: number) => {
|
|
152
|
+
const abs = win().start + i;
|
|
153
|
+
const isSelected = abs === sel();
|
|
154
|
+
const ms = entry.endTime ? entry.durationMs : (now() - entry.startTime);
|
|
155
|
+
return (
|
|
156
|
+
<box flexDirection="row" paddingX={1}
|
|
157
|
+
backgroundColor={isSelected ? ui.bgInput : undefined}
|
|
158
|
+
onMouseDown={() => setSel(abs)}>
|
|
159
|
+
<text fg={statusColor(entry.status)}>{(isSelected ? "> " : " ") + statusGlyph(entry.status) + " "}</text>
|
|
160
|
+
<text fg={isSelected ? ui.primary : ui.fg}>{entry.agent + " "}</text>
|
|
161
|
+
<text fg={ui.fgMuted}>{fmtDuration(ms) + " "}</text>
|
|
162
|
+
<text fg={ui.fgMuted}>{fmtCost(entry.costUsd) + " \u00B7 "}</text>
|
|
163
|
+
<text fg={ui.fgDim}>{(entry.prompt || "").replace(/\s+/g, " ").slice(0, 44)}</text>
|
|
164
|
+
</box>
|
|
165
|
+
);
|
|
166
|
+
})}
|
|
167
|
+
</box>
|
|
168
|
+
</Show>
|
|
169
|
+
<Show when={statusMsg()}>
|
|
170
|
+
<text fg={ui.fgMuted}>{statusMsg()}</text>
|
|
171
|
+
</Show>
|
|
172
|
+
</ModalFrame>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function SubagentDetailPanel() {
|
|
177
|
+
let id: any = null;
|
|
178
|
+
const [, setTick] = createSignal(0);
|
|
179
|
+
onMount(() => { id = setInterval(() => setTick(v => v + 1), 500); });
|
|
180
|
+
onCleanup(() => { if (id) clearInterval(id); });
|
|
181
|
+
|
|
182
|
+
useKeyboard((key: any) => {
|
|
183
|
+
const ks = kbs.keyString(key);
|
|
184
|
+
if (kbs.is("modal_cancel", ks)) closeModal();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const entry = createMemo<SubagentEntry | null>(() => {
|
|
188
|
+
setTick();
|
|
189
|
+
const m = modal();
|
|
190
|
+
const runId = m && m.runId;
|
|
191
|
+
if (!runId) return null;
|
|
192
|
+
const live = activeSubagents().get(runId);
|
|
193
|
+
if (live) return live;
|
|
194
|
+
return subagentHistory().find(h => h.runId === runId) || null;
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const now = () => Date.now();
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<ModalFrame title="Subagent detail" subtitle="Esc close" footer="Esc close">
|
|
201
|
+
<Show when={entry()} fallback={<text fg={ui.fgMuted}>Subagent not found</text>}>
|
|
202
|
+
<box flexDirection="column">
|
|
203
|
+
<text fg={ui.primary}>{(entry() as any).agent + " " + statusGlyph((entry() as any).status) + " " + (entry() as any).status}</text>
|
|
204
|
+
<text fg={ui.fgMuted}>{"id: " + (entry() as any).runId}</text>
|
|
205
|
+
<text fg={ui.fgMuted}>{"started: " + new Date((entry() as any).startTime).toISOString() + ((entry() as any).endTime ? " ended: " + new Date((entry() as any).endTime).toISOString() : " (running)")}</text>
|
|
206
|
+
<text fg={ui.fgMuted}>{"duration: " + ((entry() as any).endTime ? fmtDuration((entry() as any).durationMs) : fmtDuration(now() - (entry() as any).startTime)) + " cost: " + fmtCost((entry() as any).costUsd) + " tokens: " + fmtTokens((entry() as any).tokensIn, (entry() as any).tokensOut)}</text>
|
|
207
|
+
<text fg={ui.fgMuted}>{"prompt: " + ((entry() as any).prompt || "").slice(0, 500)}</text>
|
|
208
|
+
<Show when={(entry() as any).toolLog && (entry() as any).toolLog.length > 0}>
|
|
209
|
+
<text fg={ui.fgMuted}>{"tool log: " + (entry() as any).toolLog.join(" \u00B7 ")}</text>
|
|
210
|
+
</Show>
|
|
211
|
+
<text fg={ui.fg}>output</text>
|
|
212
|
+
<text fg={ui.fgDim}>{((entry() as any).content || "(empty)").slice(0, 4000)}</text>
|
|
213
|
+
</box>
|
|
214
|
+
</Show>
|
|
215
|
+
</ModalFrame>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// ToastOverlay -- transient confirmations float above the input bar (bottom-
|
|
2
|
+
// right) and auto-dismiss after a few seconds. Never shown in the chat area.
|
|
3
|
+
import { Show, For } from "solid-js";
|
|
4
|
+
import { palette } from "../theme.ts";
|
|
5
|
+
import { toasts } from "../store.ts";
|
|
6
|
+
|
|
7
|
+
const ui = palette("loom");
|
|
8
|
+
|
|
9
|
+
export function ToastOverlay() {
|
|
10
|
+
const list = () => toasts().slice(-3);
|
|
11
|
+
return (
|
|
12
|
+
<Show when={toasts().length > 0}>
|
|
13
|
+
<box
|
|
14
|
+
position="absolute" bottom={4} right={0} zIndex={60}
|
|
15
|
+
flexDirection="column" paddingX={1} paddingY={0}
|
|
16
|
+
>
|
|
17
|
+
<For each={list()}>
|
|
18
|
+
{(t) => (
|
|
19
|
+
<box
|
|
20
|
+
border borderStyle="rounded"
|
|
21
|
+
borderColor={t.kind === "error" ? ui.error : t.kind === "ok" ? ui.success : ui.primary}
|
|
22
|
+
paddingX={1} paddingY={0} marginTop={0}
|
|
23
|
+
backgroundColor={ui.bgPanel}
|
|
24
|
+
>
|
|
25
|
+
<text fg={t.kind === "error" ? ui.error : t.kind === "ok" ? ui.success : ui.fg}>
|
|
26
|
+
{" " + t.text}
|
|
27
|
+
</text>
|
|
28
|
+
</box>
|
|
29
|
+
)}
|
|
30
|
+
</For>
|
|
31
|
+
</box>
|
|
32
|
+
</Show>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// Keybinds — configurable keybindings for the Loom TUI.
|
|
2
|
+
//
|
|
3
|
+
// Reads `keybinds`, `leader`, and `leader_timeout` from ~/.loom/tui.json
|
|
4
|
+
// (see docs/keybinds.md). OpenCode-compatible action names are accepted as
|
|
5
|
+
// aliases; unknown names are ignored with a warning. Binding values follow the
|
|
6
|
+
// same rules as OpenCode: a string with comma-separated alternatives, an
|
|
7
|
+
// array, an object ({ key }), or "none" / false to disable. "<leader>X"
|
|
8
|
+
// bindings fire after the leader key.
|
|
9
|
+
//
|
|
10
|
+
// This module is intentionally dependency-free (no Solid, no store) so it can
|
|
11
|
+
// be unit-tested in isolation; the App owns action execution and the leader
|
|
12
|
+
// state lives here as a plain boolean.
|
|
13
|
+
import { loadTuiJson } from "./tui-config.ts";
|
|
14
|
+
|
|
15
|
+
export interface KeybindAction {
|
|
16
|
+
action: string;
|
|
17
|
+
desc: string;
|
|
18
|
+
def: string | string[];
|
|
19
|
+
slash?: string;
|
|
20
|
+
opencode?: string;
|
|
21
|
+
context?: "app" | "dialog";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const KB_ACTIONS: KeybindAction[] = [
|
|
25
|
+
// ── App / session ──
|
|
26
|
+
{ action: "app_exit", desc: "Quit Loom Code", def: "ctrl+c,<leader>q", opencode: "app_exit" },
|
|
27
|
+
{ action: "command_list", desc: "Open the command palette", def: "ctrl+p", opencode: "command_list" },
|
|
28
|
+
{ action: "sidebar_toggle", desc: "Toggle the sidebar", def: "ctrl+b", opencode: "sidebar_toggle" },
|
|
29
|
+
{ action: "sidebar_cycle_tab", desc: "Cycle the sidebar tab", def: "ctrl+i" },
|
|
30
|
+
{ action: "session_interrupt", desc: "Interrupt the running task / clear the draft", def: "escape", opencode: "session_interrupt" },
|
|
31
|
+
{ action: "modal_cancel", desc: "Close the open dialog", def: "escape" },
|
|
32
|
+
{ action: "user_expand", desc: "Expand/collapse the most recent long message", def: "ctrl+e" },
|
|
33
|
+
{ action: "session_new", desc: "Start a new session", def: "<leader>n", slash: "/new", opencode: "session_new" },
|
|
34
|
+
{ action: "session_list", desc: "Browse saved sessions", def: "<leader>l", slash: "/sessions", opencode: "session_list" },
|
|
35
|
+
{ action: "session_export", desc: "Export the session to markdown", def: "<leader>x", slash: "/export", opencode: "session_export" },
|
|
36
|
+
{ action: "session_compact", desc: "Compact the conversation", def: "<leader>c", slash: "/compact", opencode: "session_compact" },
|
|
37
|
+
{ action: "model_list", desc: "Open the model picker", def: "<leader>m", slash: "/models", opencode: "model_list,model_provider_list" },
|
|
38
|
+
{ action: "agent_list", desc: "List agents", def: "<leader>a", slash: "/agents", opencode: "agent_list" },
|
|
39
|
+
{ action: "subagent_list", desc: "View subagents (live + history, cancel, details)", def: "<leader>j", slash: "/subagents" },
|
|
40
|
+
{ action: "help_show", desc: "Show help", def: "<leader>h", slash: "/help", opencode: "help_show" },
|
|
41
|
+
{ action: "editor_open", desc: "Open LOOM.md in your editor", def: "<leader>e", slash: "/editor", opencode: "editor_open" },
|
|
42
|
+
{ action: "display_thinking", desc: "Toggle thinking visibility", def: "<leader>t", slash: "/thinking", opencode: "display_thinking" },
|
|
43
|
+
{ action: "tool_details", desc: "Toggle tool detail visibility", def: "<leader>d", slash: "/details", opencode: "tool_details" },
|
|
44
|
+
{ action: "app_settings", desc: "Open settings", def: "<leader>s", slash: "/settings" },
|
|
45
|
+
{ action: "app_undo", desc: "Undo the last exchange", def: "<leader>u", slash: "/undo" },
|
|
46
|
+
{ action: "app_redo", desc: "Redo the last undone exchange", def: "<leader>r", slash: "/redo" },
|
|
47
|
+
{ action: "mode_build", desc: "Switch to Build mode", def: "<leader>b", slash: "/build" },
|
|
48
|
+
{ action: "mode_plan", desc: "Switch to Plan mode", def: "<leader>p", slash: "/plan" },
|
|
49
|
+
{ action: "theme_list", desc: "Open the theme picker", def: "none", slash: "/theme", opencode: "theme_list" },
|
|
50
|
+
// ── Prompt input (readline-style editing) ──
|
|
51
|
+
{ action: "input_submit", desc: "Submit the prompt", def: "return", opencode: "input_submit,prompt_submit" },
|
|
52
|
+
{ action: "input_newline", desc: "Insert a newline", def: "shift+return", opencode: "input_newline" },
|
|
53
|
+
{ action: "input_paste", desc: "Paste (the terminal handles it)", def: "ctrl+v", opencode: "input_paste" },
|
|
54
|
+
{ action: "input_select_all", desc: "Select the whole draft", def: "ctrl+a", opencode: "input_select_all" },
|
|
55
|
+
{ action: "input_move_left", desc: "Move the caret left", def: "left", opencode: "input_move_left" },
|
|
56
|
+
{ action: "input_move_right", desc: "Move the caret right", def: "right", opencode: "input_move_right" },
|
|
57
|
+
{ action: "line_home", desc: "Move to the start of the draft", def: "home", opencode: "input_line_home,input_buffer_home" },
|
|
58
|
+
{ action: "line_end", desc: "Move to the end of the draft", def: "end", opencode: "input_line_end,input_buffer_end" },
|
|
59
|
+
{ action: "input_backspace", desc: "Backspace", def: "backspace", opencode: "input_backspace" },
|
|
60
|
+
{ action: "input_delete", desc: "Delete forward", def: "ctrl+d,delete", opencode: "input_delete" },
|
|
61
|
+
{ action: "prompt_autocomplete_next", desc: "Next suggestion / cycle the mode", def: "tab", opencode: "prompt.autocomplete.next,prompt.autocomplete.complete" },
|
|
62
|
+
{ action: "up_context", desc: "Up (suggestion / caret line / history)", def: "up", opencode: "input_move_up,history_previous,prompt.autocomplete.prev" },
|
|
63
|
+
{ action: "down_context", desc: "Down (suggestion / caret line / history)", def: "down", opencode: "input_move_down,history_next" },
|
|
64
|
+
// ── Dialog keys (modal lists & prompts) ──
|
|
65
|
+
{ action: "dialog_select_prev", desc: "Dialog: previous row", def: "up", context: "dialog", opencode: "dialog.select.prev" },
|
|
66
|
+
{ action: "dialog_select_next", desc: "Dialog: next row", def: "down", context: "dialog", opencode: "dialog.select.next" },
|
|
67
|
+
{ action: "dialog_select_submit", desc: "Dialog: select the row", def: "return", context: "dialog", opencode: "dialog.select.submit,dialog.prompt.submit" },
|
|
68
|
+
{ action: "dialog_select_page_up", desc: "Dialog: page up", def: "pageup", context: "dialog", opencode: "dialog.select.page_up" },
|
|
69
|
+
{ action: "dialog_select_page_down", desc: "Dialog: page down", def: "pagedown", context: "dialog", opencode: "dialog.select.page_down" },
|
|
70
|
+
{ action: "dialog_select_home", desc: "Dialog: first row", def: "home", context: "dialog", opencode: "dialog.select.home" },
|
|
71
|
+
{ action: "dialog_select_end", desc: "Dialog: last row", def: "end", context: "dialog", opencode: "dialog.select.end" },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
// ─── Config file ───
|
|
75
|
+
|
|
76
|
+
// ─── Canonical key strings ───
|
|
77
|
+
// Both the event serializer and the config parser produce the same shape:
|
|
78
|
+
// modifier+name, e.g. "ctrl+shift+y", "shift+return", "escape", "f5".
|
|
79
|
+
const NAME_ALIASES: Record<string, string> = {
|
|
80
|
+
enter: "return", esc: "escape", del: "delete", ins: "insert",
|
|
81
|
+
"page-up": "pageup", page_up: "pageup", pgup: "pageup", pgdn: "pagedown",
|
|
82
|
+
page_down: "pagedown", "page-down": "pagedown",
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export function keyString(k: any): string {
|
|
86
|
+
if (!k || !k.name) return "";
|
|
87
|
+
const parts: string[] = [];
|
|
88
|
+
if (k.ctrl) parts.push("ctrl");
|
|
89
|
+
if (k.shift) parts.push("shift");
|
|
90
|
+
if (k.meta || k.option) parts.push("alt");
|
|
91
|
+
if (k.super) parts.push("super");
|
|
92
|
+
if (k.hyper) parts.push("hyper");
|
|
93
|
+
let name = String(k.name).toLowerCase();
|
|
94
|
+
if (/^f([1-9]|1[0-2])$/.test(name)) name = name.toLowerCase();
|
|
95
|
+
else if (NAME_ALIASES[name]) name = NAME_ALIASES[name];
|
|
96
|
+
parts.push(name);
|
|
97
|
+
return parts.join("+");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const MODIFIER_ALIASES: Record<string, string> = {
|
|
101
|
+
ctrl: "ctrl", control: "ctrl",
|
|
102
|
+
shift: "shift",
|
|
103
|
+
alt: "alt", meta: "alt", option: "alt",
|
|
104
|
+
super: "super", cmd: "super", win: "super", mod: "super",
|
|
105
|
+
hyper: "hyper",
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
function canonKey(s: string): string {
|
|
109
|
+
if (typeof s !== "string") return "";
|
|
110
|
+
const t = s.trim();
|
|
111
|
+
if (!t) return "";
|
|
112
|
+
if (t.startsWith("<leader>")) {
|
|
113
|
+
const rest = canonKey(t.slice(8));
|
|
114
|
+
return rest ? "<leader>" + rest : "";
|
|
115
|
+
}
|
|
116
|
+
const parts = t.split("+").map(p => p.trim().toLowerCase()).filter(Boolean);
|
|
117
|
+
const mods: string[] = [];
|
|
118
|
+
let name = "";
|
|
119
|
+
for (const p of parts) {
|
|
120
|
+
const m = MODIFIER_ALIASES[p];
|
|
121
|
+
if (m) { if (mods.indexOf(m) < 0) mods.push(m); continue; }
|
|
122
|
+
if (!name) name = NAME_ALIASES[p] || (/^f([1-9]|1[0-2])$/i.test(p) ? p.toLowerCase() : p);
|
|
123
|
+
}
|
|
124
|
+
const order = ["ctrl", "shift", "alt", "super", "hyper"];
|
|
125
|
+
const out = order.filter(m => mods.indexOf(m) >= 0);
|
|
126
|
+
if (name) out.push(name);
|
|
127
|
+
return out.join("+");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ─── Binding value parsing ───
|
|
131
|
+
// Returns the list of raw key specs, or null when the binding is disabled.
|
|
132
|
+
function parseBindings(value: any): string[] | null {
|
|
133
|
+
if (value === false || value === null) return null;
|
|
134
|
+
if (value === undefined) return null;
|
|
135
|
+
if (typeof value === "string") {
|
|
136
|
+
if (value.trim() === "" || value.trim().toLowerCase() === "none") return null;
|
|
137
|
+
return value.split(",").map(s => s.trim()).filter(Boolean);
|
|
138
|
+
}
|
|
139
|
+
if (Array.isArray(value)) return value.map(v => String(v).trim()).filter(Boolean);
|
|
140
|
+
if (typeof value === "object" && value && value.key !== undefined) return [String(value.key).trim()];
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ─── Alias resolution (opencode-compatible names) ───
|
|
145
|
+
const ALIAS_TO_ACTION: Record<string, string> = {};
|
|
146
|
+
const ACTION_BY_NAME: Record<string, KeybindAction> = {};
|
|
147
|
+
for (const a of KB_ACTIONS) {
|
|
148
|
+
ACTION_BY_NAME[a.action] = a;
|
|
149
|
+
for (const alias of (a.opencode || "").split(",").map(s => s.trim()).filter(Boolean)) {
|
|
150
|
+
ALIAS_TO_ACTION[alias] = a.action;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function resolveActionName(name: string): string | undefined {
|
|
154
|
+
const n = name.trim();
|
|
155
|
+
if (ACTION_BY_NAME[n]) return n;
|
|
156
|
+
if (ALIAS_TO_ACTION[n]) return ALIAS_TO_ACTION[n];
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ─── Resolved maps ───
|
|
161
|
+
let appMap = new Map<string, string>(); // keystring → action (app context)
|
|
162
|
+
let dialogMap = new Map<string, string>(); // keystring → action (dialog context)
|
|
163
|
+
let modalMap = new Map<string, string>(); // keystring → "modal_cancel" (own map: it
|
|
164
|
+
// shares its key with session_interrupt)
|
|
165
|
+
let leaderMap = new Map<string, string>(); // "leader:<keystring>" → action
|
|
166
|
+
let labels = new Map<string, string[]>(); // action → display strings
|
|
167
|
+
let leader = "ctrl+x";
|
|
168
|
+
let leaderTimeoutMs = 2000;
|
|
169
|
+
let configWarnings: string[] = [];
|
|
170
|
+
|
|
171
|
+
function removeAction(action: string) {
|
|
172
|
+
for (const m of [appMap, dialogMap, modalMap, leaderMap]) {
|
|
173
|
+
for (const [ks, a] of Array.from(m.entries())) {
|
|
174
|
+
if (a === action) m.delete(ks);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
labels.delete(action);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function addBinding(mode: "app" | "dialog" | "leader", keystring: string, action: string) {
|
|
181
|
+
const target = mode === "leader" ? leaderMap
|
|
182
|
+
: action === "modal_cancel" ? modalMap
|
|
183
|
+
: mode === "dialog" ? dialogMap
|
|
184
|
+
: appMap;
|
|
185
|
+
target.set(mode === "leader" ? "leader:" + keystring : keystring, action);
|
|
186
|
+
const disp = mode === "leader" ? "<leader>" + keystring : keystring;
|
|
187
|
+
const l = labels.get(action) || [];
|
|
188
|
+
l.push(disp);
|
|
189
|
+
labels.set(action, l);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function actionContext(action: string): "app" | "dialog" {
|
|
193
|
+
return ACTION_BY_NAME[action]?.context === "dialog" ? "dialog" : "app";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function reload() {
|
|
197
|
+
appMap = new Map();
|
|
198
|
+
dialogMap = new Map();
|
|
199
|
+
modalMap = new Map();
|
|
200
|
+
leaderMap = new Map();
|
|
201
|
+
labels = new Map();
|
|
202
|
+
configWarnings = [];
|
|
203
|
+
const cfg = loadTuiJson();
|
|
204
|
+
|
|
205
|
+
// Leader key + timeout.
|
|
206
|
+
const lv = cfg.leader;
|
|
207
|
+
if (lv === false || lv === "none" || lv === "") leader = "";
|
|
208
|
+
else leader = lv == null ? "ctrl+x" : canonKey(String(lv));
|
|
209
|
+
const lt = Number(cfg.leader_timeout);
|
|
210
|
+
leaderTimeoutMs = Number.isFinite(lt) && lt > 0 ? lt : 2000;
|
|
211
|
+
cancelLeader();
|
|
212
|
+
|
|
213
|
+
// Defaults first…
|
|
214
|
+
for (const a of KB_ACTIONS) {
|
|
215
|
+
const binds = parseBindings(a.def);
|
|
216
|
+
if (!binds) continue;
|
|
217
|
+
for (const b of binds) {
|
|
218
|
+
const c = canonKey(b);
|
|
219
|
+
if (!c) continue;
|
|
220
|
+
if (c.startsWith("<leader>")) addBinding("leader", c.slice(8), a.action);
|
|
221
|
+
else addBinding(a.context === "dialog" ? "dialog" : "app", c, a.action);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// …then user overrides (a configured value fully replaces the defaults).
|
|
226
|
+
const ub = cfg.keybinds && typeof cfg.keybinds === "object" ? cfg.keybinds : {};
|
|
227
|
+
let modalCancelConfigured = false;
|
|
228
|
+
for (const name of Object.keys(ub)) {
|
|
229
|
+
const action = resolveActionName(name);
|
|
230
|
+
if (!action) { configWarnings.push("unknown keybind action: " + name); continue; }
|
|
231
|
+
if (action === "modal_cancel") modalCancelConfigured = true;
|
|
232
|
+
const binds = parseBindings(ub[name]);
|
|
233
|
+
removeAction(action);
|
|
234
|
+
if (!binds) continue;
|
|
235
|
+
for (const b of binds) {
|
|
236
|
+
const c = canonKey(b);
|
|
237
|
+
if (!c) continue;
|
|
238
|
+
if (c.startsWith("<leader>")) addBinding("leader", c.slice(8), action);
|
|
239
|
+
else addBinding(actionContext(action), c, action);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// modal_cancel mirrors session_interrupt's keys unless configured separately,
|
|
244
|
+
// so ESC both clears the input and closes an open modal (legacy behavior).
|
|
245
|
+
if (!modalCancelConfigured) {
|
|
246
|
+
removeAction("modal_cancel");
|
|
247
|
+
for (const m of [appMap, leaderMap]) {
|
|
248
|
+
for (const [ks, a] of Array.from(m.entries())) {
|
|
249
|
+
if (a !== "session_interrupt") continue;
|
|
250
|
+
const plain = ks.startsWith("leader:") ? ks.slice(7) : ks;
|
|
251
|
+
addBinding(m === leaderMap ? "leader" : "app", plain, "modal_cancel");
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ─── Leader state ───
|
|
258
|
+
let leaderArmed = false;
|
|
259
|
+
let leaderTimer: any = null;
|
|
260
|
+
|
|
261
|
+
export function isLeaderPending() { return leaderArmed; }
|
|
262
|
+
|
|
263
|
+
export function cancelLeader() {
|
|
264
|
+
leaderArmed = false;
|
|
265
|
+
if (leaderTimer) { clearTimeout(leaderTimer); leaderTimer = null; }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Returns true when ks IS the leader key (arms the leader mode). */
|
|
269
|
+
export function tapLeader(ks: string): boolean {
|
|
270
|
+
if (!leader || !ks || ks !== leader) return false;
|
|
271
|
+
leaderArmed = true;
|
|
272
|
+
if (leaderTimer) clearTimeout(leaderTimer);
|
|
273
|
+
leaderTimer = setTimeout(cancelLeader, leaderTimeoutMs);
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** While the leader is pending, look up the action bound to <leader>+ks. */
|
|
278
|
+
export function leaderMatch(ks: string): string | undefined {
|
|
279
|
+
return leaderMap.get("leader:" + ks);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ─── Lookups ───
|
|
283
|
+
export function is(action: string, ks: string): boolean {
|
|
284
|
+
if (action === "modal_cancel") return modalMap.get(ks) === action;
|
|
285
|
+
return appMap.get(ks) === action;
|
|
286
|
+
}
|
|
287
|
+
export function dialogIs(action: string, ks: string): boolean { return dialogMap.get(ks) === action; }
|
|
288
|
+
export function leaderKey(): string { return leader; }
|
|
289
|
+
export function leaderTimeout(): number { return leaderTimeoutMs; }
|
|
290
|
+
export function slashFor(action: string): string { return ACTION_BY_NAME[action]?.slash || ""; }
|
|
291
|
+
export function warnings(): string[] { return configWarnings.slice(); }
|
|
292
|
+
|
|
293
|
+
/** Human-readable label for an action (first bound key), for help/footers. */
|
|
294
|
+
export function label(action: string): string {
|
|
295
|
+
const l = labels.get(action);
|
|
296
|
+
return l && l.length ? l[0] : "none";
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Full description for /keybinds: one line per bound action. */
|
|
300
|
+
export function describeAll(): string {
|
|
301
|
+
const lines: string[] = [];
|
|
302
|
+
lines.push("Keybinds (from ~/.loom/tui.json):");
|
|
303
|
+
for (const a of KB_ACTIONS) {
|
|
304
|
+
const l = labels.get(a.action);
|
|
305
|
+
if (!l || !l.length) continue;
|
|
306
|
+
lines.push(" " + a.action.padEnd(24) + " " + l.join(", "));
|
|
307
|
+
}
|
|
308
|
+
if (leader) lines.push(" " + "leader".padEnd(24) + " " + leader + " (timeout " + leaderTimeout() + "ms)");
|
|
309
|
+
else lines.push(" " + "leader".padEnd(24) + " disabled");
|
|
310
|
+
if (configWarnings.length) {
|
|
311
|
+
lines.push("Warnings:");
|
|
312
|
+
for (const w of configWarnings) lines.push(" " + w);
|
|
313
|
+
}
|
|
314
|
+
lines.push("Edit ~/.loom/tui.json, then restart. See docs/keybinds.md.");
|
|
315
|
+
return lines.join("\n");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
reload();
|