opencode-clm 0.2.0 → 0.3.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 +1 -1
- package/README.md +77 -66
- package/index.ts +730 -36
- package/package.json +6 -6
- package/src/budget.ts +6 -6
- package/src/channel.ts +187 -0
- package/src/clm.ts +369 -20
- package/src/compaction.ts +243 -0
- package/src/context-document.ts +2 -1
- package/src/continuity.ts +127 -1
- package/src/history.ts +109 -0
- package/src/mirror-store.ts +40 -2
- package/src/opencode.ts +59 -0
- package/src/overrides.ts +13 -1
- package/src/panel/command.ts +8 -2
- package/src/panel/files.ts +4 -0
- package/src/panel/model.ts +23 -1
- package/src/panel/timeline.ts +7 -1
- package/src/panel/view.ts +10 -1
- package/src/projection.ts +8 -0
- package/src/session-files.ts +134 -32
- package/src/settings-table.ts +51 -3
- package/src/settings.ts +31 -3
- package/src/tui/complete.ts +102 -0
- package/src/tui/data.ts +83 -3
- package/src/tui/intercept.ts +10 -6
- package/src/tui/locator.ts +101 -0
- package/src/tui/panel.ts +11 -0
- package/src/tui/plugin.ts +219 -33
- package/src/tui/remote.ts +199 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Tab completion for typed `/clm …` lines in the TUI prompt. OpenCode has no argument
|
|
2
|
+
// completion for commands (its slash menu closes at the first space), so the TUI plugin
|
|
3
|
+
// completes inline: Tab extends the line to the longest common prefix of the matching items
|
|
4
|
+
// and lists them when several remain. Items follow pi-clm's `getArgumentCompletions`
|
|
5
|
+
// (pi src/index.ts:961-980) plus `budget` and setting values. Written for this package.
|
|
6
|
+
|
|
7
|
+
import { PAGES } from "../panel/command.ts";
|
|
8
|
+
import { SETTING_NAMES, settingDescriptor } from "../settings-table.ts";
|
|
9
|
+
import type { FocusedPrompt, InterceptContext } from "./intercept.ts";
|
|
10
|
+
|
|
11
|
+
const PREFIX = "/clm ";
|
|
12
|
+
|
|
13
|
+
/** pi's word list: pages, status, config, `config <setting>`, config reset, on, off, reset, path; plus budget. */
|
|
14
|
+
export const CLM_COMPLETIONS: readonly string[] = [
|
|
15
|
+
...PAGES,
|
|
16
|
+
"status",
|
|
17
|
+
"config",
|
|
18
|
+
...SETTING_NAMES.map((name) => `config ${name}`),
|
|
19
|
+
"config reset",
|
|
20
|
+
"on",
|
|
21
|
+
"off",
|
|
22
|
+
"reset",
|
|
23
|
+
"path",
|
|
24
|
+
"budget",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/** Items for the text after `/clm `: subcommands, or a setting's choices after `config <setting> `. */
|
|
28
|
+
export function clmCompletions(args: string): string[] {
|
|
29
|
+
const lower = args.toLowerCase();
|
|
30
|
+
const value = /^config\s+(\S+)\s+(.*)$/i.exec(args);
|
|
31
|
+
if (value) {
|
|
32
|
+
const choices = settingDescriptor(value[1]!)?.choices ?? [];
|
|
33
|
+
return choices
|
|
34
|
+
.filter((choice) => choice.toLowerCase().startsWith(value[2]!.toLowerCase()))
|
|
35
|
+
.map((choice) => `config ${value[1]} ${choice}`);
|
|
36
|
+
}
|
|
37
|
+
return CLM_COMPLETIONS.filter((item) => item.startsWith(lower));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function commonPrefix(items: readonly string[]): string {
|
|
41
|
+
let prefix = items[0] ?? "";
|
|
42
|
+
for (const item of items) {
|
|
43
|
+
while (!item.startsWith(prefix)) prefix = prefix.slice(0, -1);
|
|
44
|
+
}
|
|
45
|
+
return prefix;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface Completion {
|
|
49
|
+
/** The new prompt text; the same text when no item extends it. */
|
|
50
|
+
text: string;
|
|
51
|
+
/** Items still matching; more than one when the line stays ambiguous. */
|
|
52
|
+
items: string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** `undefined` unless `text` is `/clm ` followed by something an item completes. */
|
|
56
|
+
export function completeClmLine(text: string): Completion | undefined {
|
|
57
|
+
if (!text.startsWith(PREFIX)) return undefined;
|
|
58
|
+
const args = text.slice(PREFIX.length).replace(/^\s+/, "");
|
|
59
|
+
const items = clmCompletions(args);
|
|
60
|
+
if (items.length === 0) return undefined;
|
|
61
|
+
if (items.length === 1) return { text: `${PREFIX}${items[0]} `, items };
|
|
62
|
+
const common = commonPrefix(items);
|
|
63
|
+
return { text: common.length > args.length ? `${PREFIX}${common}` : text, items };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface TabDeps {
|
|
67
|
+
focused(): (FocusedPrompt & { cursorOffset?: unknown }) | null | undefined;
|
|
68
|
+
/** False when `/clm` is not this package's (as for Enter). */
|
|
69
|
+
owned(): boolean;
|
|
70
|
+
/** Shows the remaining items when Tab cannot extend the line. */
|
|
71
|
+
show(items: readonly string[]): void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Called for every key. Consumes Tab (no modifiers) on a `/clm …` line with the cursor at the
|
|
76
|
+
* end when an item matches; everything else, `/clm` without a space (OpenCode's own slash
|
|
77
|
+
* menu completes that) and lines with no match included, passes through to the host's Tab.
|
|
78
|
+
* Returns the completion applied, for tests.
|
|
79
|
+
*/
|
|
80
|
+
export function interceptTab(context: InterceptContext, deps: TabDeps): Completion | undefined {
|
|
81
|
+
const event = context.event;
|
|
82
|
+
if (event.name !== "tab" || event.shift || event.ctrl || event.meta) return undefined;
|
|
83
|
+
const focused = deps.focused();
|
|
84
|
+
if (!focused || typeof focused.plainText !== "string" || typeof focused.setText !== "function") return undefined;
|
|
85
|
+
const text = focused.plainText;
|
|
86
|
+
if (typeof focused.cursorOffset === "number" && focused.cursorOffset !== text.length) return undefined;
|
|
87
|
+
const completion = completeClmLine(text);
|
|
88
|
+
if (!completion) return undefined;
|
|
89
|
+
try {
|
|
90
|
+
if (!deps.owned()) return undefined;
|
|
91
|
+
} catch {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
context.consume();
|
|
95
|
+
if (completion.text !== text) {
|
|
96
|
+
focused.setText(completion.text);
|
|
97
|
+
if (typeof focused.cursorOffset === "number") focused.cursorOffset = completion.text.length;
|
|
98
|
+
} else {
|
|
99
|
+
deps.show(completion.items);
|
|
100
|
+
}
|
|
101
|
+
return completion;
|
|
102
|
+
}
|
package/src/tui/data.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { resolveBudget } from "../budget.ts";
|
|
6
6
|
import type { LatestUsage, PanelModel, PanelSettings, SettingRow } from "../panel/model.ts";
|
|
7
|
-
import { formatTokenCount } from "../panel/timeline.ts";
|
|
7
|
+
import { formatTokenCount, type TimelinePoint } from "../panel/timeline.ts";
|
|
8
8
|
import type { ClmSettings } from "../settings.ts";
|
|
9
9
|
import { applyOverrides, changedSettings, sanitizeOverrides, SETTINGS_TABLE, type FormatContext, type SettingsValues } from "../settings-table.ts";
|
|
10
10
|
|
|
@@ -109,7 +109,7 @@ export function fallbackBudget(settings: ClmSettings, limits: { context?: number
|
|
|
109
109
|
*/
|
|
110
110
|
export function settingsView(
|
|
111
111
|
values: { base: SettingsValues; effective: SettingsValues },
|
|
112
|
-
model: Pick<PanelModel, "budgetInfo" | "timeline" | "mirrorPath"
|
|
112
|
+
model: Pick<PanelModel, "budgetInfo" | "timeline" | "mirrorPath"> & Partial<Pick<PanelModel, "calibration" | "steering" | "annotations">>,
|
|
113
113
|
options: { format?: FormatContext; warning?: string } = {},
|
|
114
114
|
): PanelSettings {
|
|
115
115
|
const changed = new Set<string>(changedSettings(values.base, values.effective));
|
|
@@ -124,9 +124,10 @@ export function settingsView(
|
|
|
124
124
|
}));
|
|
125
125
|
const latest = model.timeline.points.at(-1);
|
|
126
126
|
const summary: string[] = [];
|
|
127
|
-
if (latest) summary.push(
|
|
127
|
+
if (latest) summary.push(sizeLine(latest, model.budgetInfo?.budget));
|
|
128
128
|
const info = model.budgetInfo;
|
|
129
129
|
if (info?.overhead !== undefined) summary.push(`Fixed overhead ~${formatTokenCount(info.overhead)} · usable ${formatTokenCount(info.usable ?? 0)}`);
|
|
130
|
+
summary.push(...detailLines(values.effective, model));
|
|
130
131
|
summary.push(`Files mirror ${model.mirrorPath}`);
|
|
131
132
|
return {
|
|
132
133
|
rows,
|
|
@@ -136,6 +137,85 @@ export function settingsView(
|
|
|
136
137
|
};
|
|
137
138
|
}
|
|
138
139
|
|
|
140
|
+
/**
|
|
141
|
+
* pi's settings-page Size line ("next request ~E of B · last request M (provider count)").
|
|
142
|
+
* pi's "next request" is the estimate of the request built last, here the newest request.
|
|
143
|
+
* The line shows its provider count and the logged estimate side by side. The budget is
|
|
144
|
+
* today's, so after a budget change it is off until the next request.
|
|
145
|
+
*/
|
|
146
|
+
export function sizeLine(latest: Pick<TimelinePoint, "tokens" | "measured" | "estimated">, budget?: number): string {
|
|
147
|
+
const of = budget !== undefined ? ` of ${formatTokenCount(budget)}` : "";
|
|
148
|
+
if (!latest.measured) return `Size last request ~${formatTokenCount(latest.tokens)}${of} (estimate)`;
|
|
149
|
+
const estimate = latest.estimated !== undefined ? ` · estimated ~${formatTokenCount(latest.estimated)}` : "";
|
|
150
|
+
return `Size last request ${formatTokenCount(latest.tokens)}${of} (provider count)${estimate}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Footer text in the session prompt's right slot, as pi's status line:
|
|
155
|
+
* `clm 12k / 32k · r2` (newest size against the budget in force), `clm off · r2`, or
|
|
156
|
+
* undefined before the session has CLM data.
|
|
157
|
+
*/
|
|
158
|
+
export function footerText(model: Pick<PanelModel, "found" | "enabled" | "revision" | "timeline" | "budget">): string | undefined {
|
|
159
|
+
if (!model.found) return undefined;
|
|
160
|
+
if (!model.enabled) return `clm off · r${model.revision}`;
|
|
161
|
+
const latest = model.timeline.points.at(-1);
|
|
162
|
+
const size = latest === undefined
|
|
163
|
+
? ""
|
|
164
|
+
: ` ${latest.measured ? "" : "~"}${formatTokenCount(latest.tokens)}${model.budget !== undefined ? ` / ${formatTokenCount(model.budget)}` : ""}`;
|
|
165
|
+
return `clm${size} · r${model.revision}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Key of the settings page's "Reset to defaults" row, and the choice that resets. */
|
|
169
|
+
export const RESET_ROW = "reset";
|
|
170
|
+
export const RESET_NOW = "reset now";
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* pi's "Reset to defaults" row: Enter cycles to "reset now", which drops this session's
|
|
174
|
+
* changes (the same as `/clm config reset`). With nothing changed Enter does nothing.
|
|
175
|
+
*/
|
|
176
|
+
export function resetRow(changed: number): SettingRow {
|
|
177
|
+
const value = changed === 0 ? "nothing changed" : `${changed} changed`;
|
|
178
|
+
return {
|
|
179
|
+
key: RESET_ROW,
|
|
180
|
+
label: "Reset to defaults",
|
|
181
|
+
value,
|
|
182
|
+
description: "Drop this session's changes; the plugin options, environment and defaults apply again from the next request. Same as /clm config reset. Enter on \"reset now\" resets at once.",
|
|
183
|
+
choices: changed === 0 ? [value] : [value, RESET_NOW],
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Settings-page details after the size lines (pi's settings summary): calibration, the
|
|
189
|
+
* overflow guard's limit, the steering document with its hash, annotation counts.
|
|
190
|
+
*/
|
|
191
|
+
export function detailLines(
|
|
192
|
+
effective: SettingsValues,
|
|
193
|
+
model: Pick<PanelModel, "budgetInfo"> & Partial<Pick<PanelModel, "calibration" | "steering" | "annotations">>,
|
|
194
|
+
): string[] {
|
|
195
|
+
const lines: string[] = [];
|
|
196
|
+
const calibration = model.calibration;
|
|
197
|
+
if (calibration) {
|
|
198
|
+
lines.push(calibration.samples === 0
|
|
199
|
+
? "Estimate not calibrated yet (characters ÷ 4 until the provider reports a size)"
|
|
200
|
+
: `Estimate ×${calibration.factor.toFixed(2)}, calibrated from ${calibration.samples} provider count${calibration.samples === 1 ? "" : "s"}`);
|
|
201
|
+
}
|
|
202
|
+
const limit = model.budgetInfo?.limit;
|
|
203
|
+
lines.push(effective.settings.guard === "off"
|
|
204
|
+
? "Guard off"
|
|
205
|
+
: `Guard withholds the oldest tool results above ${limit === undefined ? "budget − reserve" : formatTokenCount(limit)}`);
|
|
206
|
+
// pi appends the native-compaction state; `auto` and `on` leave OpenCode's own config.
|
|
207
|
+
if (effective.settings.compaction === "off") lines[lines.length - 1] += " · OpenCode's automatic compaction off";
|
|
208
|
+
// The hash prefix identifies an experiment arm (compare with sha256sum).
|
|
209
|
+
if (model.steering) lines.push(`Steering ${model.steering.name} (sha256 ${model.steering.hash}…)`);
|
|
210
|
+
const counts = model.annotations;
|
|
211
|
+
if (counts) {
|
|
212
|
+
lines.push(counts.total === 0
|
|
213
|
+
? "Annotations none"
|
|
214
|
+
: `Annotations ${counts.active} continuity/pin · ${counts.archived} archive · ${counts.total} total`);
|
|
215
|
+
}
|
|
216
|
+
return lines;
|
|
217
|
+
}
|
|
218
|
+
|
|
139
219
|
/**
|
|
140
220
|
* The server's base settings, from snapshot.json `base` (the server writes it each
|
|
141
221
|
* request), applied over the TUI's own resolution so non-table settings (mirrorDir…) stay.
|
package/src/tui/intercept.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// The TUI's Enter intercept for typed `/clm …` lines: handled in the TUI with no model turn
|
|
2
|
-
//
|
|
1
|
+
// The TUI's Enter intercept for typed `/clm …` lines: handled in the TUI with no model turn.
|
|
2
|
+
// `/clm reset` (the server owns state.json) is consumed only when `serverChannel` says the
|
|
3
|
+
// TUI can hand it to the server plugin without a turn (src/channel.ts). Host-free so it can be unit-tested with
|
|
3
4
|
// a fake key context and focused prompt. Written for this package.
|
|
4
5
|
|
|
5
6
|
import { parseClmCommand, type ClmCommand } from "../panel/command.ts";
|
|
@@ -20,8 +21,10 @@ export interface InterceptDeps {
|
|
|
20
21
|
focused(): FocusedPrompt | null | undefined;
|
|
21
22
|
/** False when `/clm` is not this package's to handle (`commands: false`, or a user-defined `/clm`). */
|
|
22
23
|
owned(): boolean;
|
|
23
|
-
/** Runs a command the TUI handles. */
|
|
24
|
-
handle(command:
|
|
24
|
+
/** Runs a command the TUI handles (a `server` one only when `serverChannel()` is true). */
|
|
25
|
+
handle(command: ClmCommand): Promise<void>;
|
|
26
|
+
/** True when `server` commands (`/clm reset`) go over the turn-free channel; absent = pass through. */
|
|
27
|
+
serverChannel?(): boolean;
|
|
25
28
|
/** Shows an error from `handle`. */
|
|
26
29
|
report(message: string): void;
|
|
27
30
|
}
|
|
@@ -31,7 +34,7 @@ export interface InterceptDeps {
|
|
|
31
34
|
* handles; everything else passes through untouched: other text, `/clm-compact`, `/clm
|
|
32
35
|
* reset`, modified Enter, a prompt without the expected internals, and every `/clm` line
|
|
33
36
|
* when the command belongs to someone else. A usage error keeps the typed text so it can be
|
|
34
|
-
* fixed. Returns the command handled, for tests.
|
|
37
|
+
* fixed. With `serverChannel`, `/clm reset` is consumed as well. Returns the command handled, for tests.
|
|
35
38
|
*/
|
|
36
39
|
export function interceptEnter(context: InterceptContext, deps: InterceptDeps): ClmCommand | undefined {
|
|
37
40
|
const event = context.event;
|
|
@@ -39,7 +42,8 @@ export function interceptEnter(context: InterceptContext, deps: InterceptDeps):
|
|
|
39
42
|
const focused = deps.focused();
|
|
40
43
|
if (!focused || typeof focused.plainText !== "string" || typeof focused.setText !== "function") return undefined;
|
|
41
44
|
const command = parseClmCommand(focused.plainText);
|
|
42
|
-
if (!command
|
|
45
|
+
if (!command) return undefined;
|
|
46
|
+
if (command.kind === "server" && !deps.serverChannel?.()) return undefined;
|
|
43
47
|
// A throw here would land in the host's key handling: fail open, let the line through.
|
|
44
48
|
let owned: boolean;
|
|
45
49
|
try {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// TUI-side helpers around the channel (src/channel.ts) that need no opentui: the per-session
|
|
2
|
+
// `locate` cache and `/clm reset` with its fallback. Written for this package.
|
|
3
|
+
|
|
4
|
+
import { ChannelTimeout, LOCATE_TIMEOUT_MS, type ChannelOperation, type ChannelReply } from "../channel.ts";
|
|
5
|
+
|
|
6
|
+
export interface Location {
|
|
7
|
+
directory: string;
|
|
8
|
+
root: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** How long a failed `locate` (no answering server plugin) is remembered. */
|
|
12
|
+
export const LOCATE_RETRY_MS = 60_000;
|
|
13
|
+
/** How long a `locate` answer is trusted (a restarted server may use another mirrorDir). */
|
|
14
|
+
export const LOCATE_TTL_MS = 5 * 60_000;
|
|
15
|
+
|
|
16
|
+
export interface Locator {
|
|
17
|
+
/** The cached answer, or a new `locate` (short timeout); undefined when the server did not answer. */
|
|
18
|
+
locate(sessionID: string): Promise<Location | undefined>;
|
|
19
|
+
/** The cached answer if still fresh, without asking. */
|
|
20
|
+
fresh(sessionID: string): Location | undefined;
|
|
21
|
+
/** Drop the cached answer (reading through it failed). */
|
|
22
|
+
forget(sessionID: string): void;
|
|
23
|
+
/** `locate` in the background, once at a time per session; `onAnswer` gets a new answer. */
|
|
24
|
+
relocate(sessionID: string, onAnswer: (location: Location) => void): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createLocator(deps: {
|
|
28
|
+
ask(sessionID: string, operation: ChannelOperation, timeoutMs?: number): Promise<ChannelReply>;
|
|
29
|
+
now?: () => number;
|
|
30
|
+
ttlMs?: number;
|
|
31
|
+
retryMs?: number;
|
|
32
|
+
}): Locator {
|
|
33
|
+
const now = deps.now ?? Date.now;
|
|
34
|
+
const ttl = deps.ttlMs ?? LOCATE_TTL_MS;
|
|
35
|
+
const retry = deps.retryMs ?? LOCATE_RETRY_MS;
|
|
36
|
+
const cache = new Map<string, { at: number; value?: Location }>();
|
|
37
|
+
const running = new Set<string>();
|
|
38
|
+
const entry = (sessionID: string) => {
|
|
39
|
+
const known = cache.get(sessionID);
|
|
40
|
+
if (!known) return undefined;
|
|
41
|
+
return now() - known.at < (known.value ? ttl : retry) ? known : undefined;
|
|
42
|
+
};
|
|
43
|
+
const locate = async (sessionID: string): Promise<Location | undefined> => {
|
|
44
|
+
const known = entry(sessionID);
|
|
45
|
+
if (known) return known.value;
|
|
46
|
+
let value: Location | undefined;
|
|
47
|
+
try {
|
|
48
|
+
const reply = await deps.ask(sessionID, { op: "locate" }, LOCATE_TIMEOUT_MS);
|
|
49
|
+
if (reply.directory && reply.root) value = { directory: reply.directory, root: reply.root };
|
|
50
|
+
} catch {
|
|
51
|
+
value = undefined;
|
|
52
|
+
}
|
|
53
|
+
cache.set(sessionID, { at: now(), ...(value ? { value } : {}) });
|
|
54
|
+
return value;
|
|
55
|
+
};
|
|
56
|
+
return {
|
|
57
|
+
locate,
|
|
58
|
+
fresh: (sessionID) => entry(sessionID)?.value,
|
|
59
|
+
forget: (sessionID) => {
|
|
60
|
+
cache.delete(sessionID);
|
|
61
|
+
},
|
|
62
|
+
relocate(sessionID, onAnswer) {
|
|
63
|
+
if (running.has(sessionID)) return;
|
|
64
|
+
running.add(sessionID);
|
|
65
|
+
void locate(sessionID)
|
|
66
|
+
.then((value) => {
|
|
67
|
+
if (value) onAnswer(value);
|
|
68
|
+
})
|
|
69
|
+
.finally(() => running.delete(sessionID));
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* `/clm reset` from the TUI: over the channel (no model turn). When the server plugin does not
|
|
76
|
+
* answer (not loaded there, an older version, or CLM disabled), fall back to the server
|
|
77
|
+
* command, as before the channel: it costs one model turn, and the toast says so.
|
|
78
|
+
*/
|
|
79
|
+
export async function resetSession(deps: {
|
|
80
|
+
sessionID: string;
|
|
81
|
+
ask(sessionID: string, operation: ChannelOperation): Promise<ChannelReply>;
|
|
82
|
+
/** `api.client.session.command`, flat v2 parameters. */
|
|
83
|
+
command(parameters: { sessionID: string; command: string; arguments: string }): Promise<{ error?: unknown } | undefined>;
|
|
84
|
+
toast(message: string, variant?: "info" | "warning"): void;
|
|
85
|
+
}): Promise<"channel" | "command"> {
|
|
86
|
+
try {
|
|
87
|
+
await deps.ask(deps.sessionID, { op: "reset" });
|
|
88
|
+
return "channel";
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (!(error instanceof ChannelTimeout)) throw error;
|
|
91
|
+
deps.toast(`${error.message}. Sent /clm reset as a command instead; it costs one model turn.`, "warning");
|
|
92
|
+
// The command runs a model turn; do not wait for it.
|
|
93
|
+
void Promise.resolve()
|
|
94
|
+
.then(() => deps.command({ sessionID: deps.sessionID, command: "clm", arguments: "reset" }))
|
|
95
|
+
.then((result) => {
|
|
96
|
+
if (result?.error !== undefined) deps.toast(`/clm reset failed: ${JSON.stringify(result.error)}`, "warning");
|
|
97
|
+
})
|
|
98
|
+
.catch((failure: unknown) => deps.toast(`/clm reset failed: ${failure instanceof Error ? failure.message : String(failure)}`, "warning"));
|
|
99
|
+
return "command";
|
|
100
|
+
}
|
|
101
|
+
}
|
package/src/tui/panel.ts
CHANGED
|
@@ -66,6 +66,17 @@ export function toStyledText(lines: readonly Line[], theme: TuiThemeCurrent): St
|
|
|
66
66
|
return new StyledText(chunks);
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/** A one-line text renderable for a host slot (the footer), restyled on each `set`. */
|
|
70
|
+
export function createFooter(api: Pick<TuiPluginApi, "renderer" | "theme">, id: string): { renderable: TextRenderable; set(text: string | undefined): void } {
|
|
71
|
+
const renderable = new TextRenderable(api.renderer, { id, content: "", wrapMode: "none" });
|
|
72
|
+
return {
|
|
73
|
+
renderable,
|
|
74
|
+
set(text) {
|
|
75
|
+
renderable.content = toStyledText(text ? [[{ text, tone: "muted" }]] : [[]], api.theme.current);
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
69
80
|
export interface PanelOptions {
|
|
70
81
|
page: Page;
|
|
71
82
|
/** Reads the session files and builds the model. Never rejects with a broken panel: errors become a model. */
|