@f5xc-salesdemos/xcsh 18.8.2 → 18.9.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/package.json +7 -7
- package/src/config/settings-schema.ts +11 -0
- package/src/config/settings.ts +11 -0
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/modes/components/settings-defs.ts +9 -0
- package/src/modes/components/status-line.ts +44 -0
- package/src/modes/controllers/chord-routing.ts +37 -0
- package/src/modes/controllers/input-controller.ts +55 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5xc-salesdemos/xcsh",
|
|
4
|
-
"version": "18.
|
|
4
|
+
"version": "18.9.0",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/f5xc-salesdemos/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -47,12 +47,12 @@
|
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@agentclientprotocol/sdk": "0.16.1",
|
|
49
49
|
"@mozilla/readability": "^0.6",
|
|
50
|
-
"@f5xc-salesdemos/xcsh-stats": "18.
|
|
51
|
-
"@f5xc-salesdemos/pi-agent-core": "18.
|
|
52
|
-
"@f5xc-salesdemos/pi-ai": "18.
|
|
53
|
-
"@f5xc-salesdemos/pi-natives": "18.
|
|
54
|
-
"@f5xc-salesdemos/pi-tui": "18.
|
|
55
|
-
"@f5xc-salesdemos/pi-utils": "18.
|
|
50
|
+
"@f5xc-salesdemos/xcsh-stats": "18.9.0",
|
|
51
|
+
"@f5xc-salesdemos/pi-agent-core": "18.9.0",
|
|
52
|
+
"@f5xc-salesdemos/pi-ai": "18.9.0",
|
|
53
|
+
"@f5xc-salesdemos/pi-natives": "18.9.0",
|
|
54
|
+
"@f5xc-salesdemos/pi-tui": "18.9.0",
|
|
55
|
+
"@f5xc-salesdemos/pi-utils": "18.9.0",
|
|
56
56
|
"@sinclair/typebox": "^0.34",
|
|
57
57
|
"@xterm/headless": "^6.0",
|
|
58
58
|
"ajv": "^8.18",
|
|
@@ -657,6 +657,17 @@ export const SETTINGS_SCHEMA = {
|
|
|
657
657
|
},
|
|
658
658
|
},
|
|
659
659
|
|
|
660
|
+
"keybindings.chordTimeout": {
|
|
661
|
+
type: "number",
|
|
662
|
+
default: 1000,
|
|
663
|
+
ui: {
|
|
664
|
+
tab: "interaction",
|
|
665
|
+
label: "Chord Timeout",
|
|
666
|
+
description: "Milliseconds to wait for the second key of a chord binding before abandoning it.",
|
|
667
|
+
submenu: true,
|
|
668
|
+
},
|
|
669
|
+
},
|
|
670
|
+
|
|
660
671
|
"startup.quiet": {
|
|
661
672
|
type: "boolean",
|
|
662
673
|
default: false,
|
package/src/config/settings.ts
CHANGED
|
@@ -356,6 +356,17 @@ export class Settings {
|
|
|
356
356
|
return this.get("bashInterceptor.patterns");
|
|
357
357
|
}
|
|
358
358
|
|
|
359
|
+
/**
|
|
360
|
+
* Get the chord-binding timeout (milliseconds) clamped to the supported range.
|
|
361
|
+
* Values outside [200, 5000] are clamped so a malformed config cannot break the
|
|
362
|
+
* chord dispatcher (e.g. 0 => never abandon, huge value => leaked state).
|
|
363
|
+
*/
|
|
364
|
+
getChordTimeoutMs(): number {
|
|
365
|
+
const raw = this.get("keybindings.chordTimeout");
|
|
366
|
+
const value = typeof raw === "number" && Number.isFinite(raw) ? raw : 1000;
|
|
367
|
+
return Math.min(5000, Math.max(200, Math.round(value)));
|
|
368
|
+
}
|
|
369
|
+
|
|
359
370
|
/**
|
|
360
371
|
* Set a model role (helper for modelRoles record).
|
|
361
372
|
*/
|
|
@@ -17,17 +17,17 @@ export interface BuildInfo {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export const BUILD_INFO: BuildInfo = {
|
|
20
|
-
"version": "18.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "18.9.0",
|
|
21
|
+
"commit": "ed061104e575b3004bd0f578577b9e30d1f294a5",
|
|
22
|
+
"shortCommit": "ed06110",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v18.
|
|
25
|
-
"commitDate": "2026-04-
|
|
26
|
-
"buildDate": "2026-04-
|
|
24
|
+
"tag": "v18.9.0",
|
|
25
|
+
"commitDate": "2026-04-22T21:34:04Z",
|
|
26
|
+
"buildDate": "2026-04-22T21:57:56.107Z",
|
|
27
27
|
"dirty": false,
|
|
28
28
|
"prNumber": "",
|
|
29
29
|
"repoUrl": "https://github.com/f5xc-salesdemos/xcsh",
|
|
30
30
|
"repoSlug": "f5xc-salesdemos/xcsh",
|
|
31
|
-
"commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/
|
|
32
|
-
"releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.
|
|
31
|
+
"commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/ed061104e575b3004bd0f578577b9e30d1f294a5",
|
|
32
|
+
"releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.9.0"
|
|
33
33
|
};
|
|
@@ -221,6 +221,15 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
|
|
|
221
221
|
{ value: "15", label: "15 items" },
|
|
222
222
|
{ value: "20", label: "20 items" },
|
|
223
223
|
],
|
|
224
|
+
// Chord binding timeout (clamped to [200, 5000] ms at read time)
|
|
225
|
+
"keybindings.chordTimeout": [
|
|
226
|
+
{ value: "200", label: "200 ms", description: "Minimum — fastest abandon" },
|
|
227
|
+
{ value: "500", label: "500 ms", description: "Snappy" },
|
|
228
|
+
{ value: "1000", label: "1 second", description: "Default" },
|
|
229
|
+
{ value: "2000", label: "2 seconds", description: "Relaxed" },
|
|
230
|
+
{ value: "3000", label: "3 seconds" },
|
|
231
|
+
{ value: "5000", label: "5 seconds", description: "Maximum" },
|
|
232
|
+
],
|
|
224
233
|
// Ask timeout
|
|
225
234
|
"ask.timeout": [
|
|
226
235
|
{ value: "0", label: "Disabled" },
|
|
@@ -3,6 +3,7 @@ import type { AssistantMessage } from "@f5xc-salesdemos/pi-ai";
|
|
|
3
3
|
import { type Component, truncateToWidth, visibleWidth } from "@f5xc-salesdemos/pi-tui";
|
|
4
4
|
import { formatCount, getShellPwd } from "@f5xc-salesdemos/pi-utils";
|
|
5
5
|
import { $ } from "bun";
|
|
6
|
+
import { formatKeyHint } from "../../config/keybindings";
|
|
6
7
|
import { settings } from "../../config/settings";
|
|
7
8
|
import type { StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle } from "../../config/settings-schema";
|
|
8
9
|
import { theme } from "../../modes/theme/theme";
|
|
@@ -62,6 +63,10 @@ export class StatusLineComponent implements Component {
|
|
|
62
63
|
#sessionStartTime: number = Date.now();
|
|
63
64
|
#planModeStatus: { enabled: boolean; paused: boolean } | null = null;
|
|
64
65
|
#cwd: string = getShellPwd();
|
|
66
|
+
// Display text for a pending chord leader (e.g. "Ctrl+X-") or null when no
|
|
67
|
+
// chord is pending. Populated by the InputController's ChordDispatcher
|
|
68
|
+
// callbacks; rendered as a dim right-aligned segment in the top border.
|
|
69
|
+
#chordPending: string | null = null;
|
|
65
70
|
|
|
66
71
|
// Git status caching (1s TTL)
|
|
67
72
|
#cachedGitStatus: {
|
|
@@ -116,6 +121,33 @@ export class StatusLineComponent implements Component {
|
|
|
116
121
|
this.#planModeStatus = status ?? null;
|
|
117
122
|
}
|
|
118
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Called by the InputController's ChordDispatcher when a chord leader is
|
|
126
|
+
* pressed. Displays e.g. "Ctrl+X-" in the top border so the user knows a
|
|
127
|
+
* partial chord is active and the dispatcher is waiting for the 2nd key.
|
|
128
|
+
*/
|
|
129
|
+
setChordPending(leader: string): void {
|
|
130
|
+
// formatKeyHint's param type is KeyId (a template-literal union) but its
|
|
131
|
+
// implementation only needs a "+"-separated key string, which is exactly
|
|
132
|
+
// what the chord dispatcher hands us. Cast so callers can pass a plain
|
|
133
|
+
// string without having to thread KeyId through the controller API.
|
|
134
|
+
const next = `${formatKeyHint(leader as Parameters<typeof formatKeyHint>[0])}-`;
|
|
135
|
+
if (this.#chordPending === next) return;
|
|
136
|
+
this.#chordPending = next;
|
|
137
|
+
this.#onStatusChanged?.();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Called when the chord is dispatched, abandoned, or times out. Clears the
|
|
142
|
+
* pending indicator. No-op when no chord is pending (avoids spurious
|
|
143
|
+
* re-renders).
|
|
144
|
+
*/
|
|
145
|
+
clearChordPending(): void {
|
|
146
|
+
if (this.#chordPending === null) return;
|
|
147
|
+
this.#chordPending = null;
|
|
148
|
+
this.#onStatusChanged?.();
|
|
149
|
+
}
|
|
150
|
+
|
|
119
151
|
setHookStatus(key: string, text: string | undefined): void {
|
|
120
152
|
if (text === undefined) {
|
|
121
153
|
this.#hookStatuses.delete(key);
|
|
@@ -486,6 +518,18 @@ export class StatusLineComponent implements Component {
|
|
|
486
518
|
});
|
|
487
519
|
}
|
|
488
520
|
|
|
521
|
+
// Chord-pending indicator: rightmost segment, dim style. Appended last
|
|
522
|
+
// so it visually sits at the far right of the top border (after any
|
|
523
|
+
// background-jobs indicator). Swallowed by truncation-from-right in the
|
|
524
|
+
// sizing loop below if the terminal is too narrow, which is acceptable.
|
|
525
|
+
if (this.#chordPending !== null) {
|
|
526
|
+
rightParts.push({
|
|
527
|
+
content: theme.fg("dim", this.#chordPending),
|
|
528
|
+
bg: defaultBg,
|
|
529
|
+
fg: defaultFg,
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
489
533
|
const topFillWidth = Math.max(0, width);
|
|
490
534
|
const left = [...leftParts];
|
|
491
535
|
const right = [...rightParts];
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ChordResult } from "@f5xc-salesdemos/pi-tui";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sinks invoked by routeChordResult. Exactly one of these fires per call
|
|
5
|
+
* (or neither, for pending / abandoned — which are intentionally swallowed
|
|
6
|
+
* so the user's partial chord never leaks into the editor buffer).
|
|
7
|
+
*/
|
|
8
|
+
export interface ChordRouteSinks {
|
|
9
|
+
action: (actionId: string) => void;
|
|
10
|
+
editor: (key: string) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Pure routing: given a ChordResult, dispatch to the right sink.
|
|
15
|
+
*
|
|
16
|
+
* - `dispatched` → action sink (keybinding matched a complete chord)
|
|
17
|
+
* - `passthrough` → editor sink (key is not a chord leader or match)
|
|
18
|
+
* - `pending` / `abandoned` → swallowed (Emacs convention: an abandoned
|
|
19
|
+
* leader does not emit the second key into the buffer)
|
|
20
|
+
*
|
|
21
|
+
* Extracted so tests can exercise routing logic without constructing a
|
|
22
|
+
* full InputController.
|
|
23
|
+
*/
|
|
24
|
+
export function routeChordResult(result: ChordResult, key: string, sinks: ChordRouteSinks): void {
|
|
25
|
+
switch (result.kind) {
|
|
26
|
+
case "dispatched":
|
|
27
|
+
sinks.action(result.action);
|
|
28
|
+
return;
|
|
29
|
+
case "passthrough":
|
|
30
|
+
sinks.editor(key);
|
|
31
|
+
return;
|
|
32
|
+
case "pending":
|
|
33
|
+
case "abandoned":
|
|
34
|
+
// Swallowed — Emacs convention for abandoned leaders.
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
2
|
import { type AgentMessage, ThinkingLevel } from "@f5xc-salesdemos/pi-agent-core";
|
|
3
3
|
import { sanitizeText } from "@f5xc-salesdemos/pi-natives";
|
|
4
|
-
import type
|
|
4
|
+
import { type AutocompleteProvider, ChordDispatcher, type SlashCommand } from "@f5xc-salesdemos/pi-tui";
|
|
5
5
|
import { $env } from "@f5xc-salesdemos/pi-utils";
|
|
6
6
|
import { settings } from "../../config/settings";
|
|
7
7
|
import { createStreamingAssistantGutter } from "../../modes/components/gutter-block";
|
|
@@ -26,9 +26,63 @@ function isExpandable(obj: unknown): obj is Expandable {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
export class InputController {
|
|
29
|
+
#dispatcher: ChordDispatcher | null = null;
|
|
30
|
+
|
|
29
31
|
constructor(private ctx: InteractiveModeContext) {}
|
|
30
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Rebuild the chord dispatcher from the current keybindings + settings.
|
|
35
|
+
* Called from setupKeyHandlers so a subsequent keybindings reload / settings
|
|
36
|
+
* change can re-init without leaking the previous pending-timer.
|
|
37
|
+
*
|
|
38
|
+
* The dispatcher callbacks drive the status-line chord-pending indicator.
|
|
39
|
+
* Optional-chaining is deliberate: Task 11 adds setChordPending/clearChordPending
|
|
40
|
+
* on StatusLineComponent, so this wiring stays safe before Task 11 lands.
|
|
41
|
+
*/
|
|
42
|
+
#initChordDispatcher(): void {
|
|
43
|
+
this.#dispatcher?.dispose();
|
|
44
|
+
this.#dispatcher = null;
|
|
45
|
+
// Feature-detect the context: partial mocks in tests may omit these methods.
|
|
46
|
+
// Production always satisfies both interfaces (see config/keybindings.ts,
|
|
47
|
+
// config/settings.ts), so the no-op branch here only protects test fakes.
|
|
48
|
+
const getBindings = this.ctx.keybindings?.getChordBindings?.bind(this.ctx.keybindings);
|
|
49
|
+
const getTimeout = this.ctx.settings?.getChordTimeoutMs?.bind(this.ctx.settings);
|
|
50
|
+
if (!getBindings || !getTimeout) return;
|
|
51
|
+
const bindings = getBindings();
|
|
52
|
+
const timeoutMs = getTimeout();
|
|
53
|
+
// Optional-chaining on methods that Task 11 adds to StatusLineComponent.
|
|
54
|
+
// Typed loosely so Task 10 can ship before Task 11 wires the methods.
|
|
55
|
+
const statusLine = this.ctx.statusLine as
|
|
56
|
+
| {
|
|
57
|
+
setChordPending?: (leader: string) => void;
|
|
58
|
+
clearChordPending?: () => void;
|
|
59
|
+
}
|
|
60
|
+
| undefined;
|
|
61
|
+
this.#dispatcher = new ChordDispatcher(bindings, timeoutMs, {
|
|
62
|
+
onPending: leader => statusLine?.setChordPending?.(leader),
|
|
63
|
+
onCleared: () => statusLine?.clearChordPending?.(),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Dispose of the chord dispatcher (clears any pending chord timer).
|
|
69
|
+
* Exposed for tests and for the outer controller lifecycle.
|
|
70
|
+
*/
|
|
71
|
+
dispose(): void {
|
|
72
|
+
this.#dispatcher?.dispose();
|
|
73
|
+
this.#dispatcher = null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Exposed for tests: returns the current dispatcher instance (or null if
|
|
78
|
+
* setupKeyHandlers has not been called yet).
|
|
79
|
+
*/
|
|
80
|
+
getChordDispatcher(): ChordDispatcher | null {
|
|
81
|
+
return this.#dispatcher;
|
|
82
|
+
}
|
|
83
|
+
|
|
31
84
|
setupKeyHandlers(): void {
|
|
85
|
+
this.#initChordDispatcher();
|
|
32
86
|
this.ctx.editor.setActionKeys("app.interrupt", this.ctx.keybindings.getKeys("app.interrupt"));
|
|
33
87
|
this.ctx.editor.shouldBypassAutocompleteOnEscape = () =>
|
|
34
88
|
Boolean(
|