@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5xc-salesdemos/xcsh",
4
- "version": "18.8.2",
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.8.2",
51
- "@f5xc-salesdemos/pi-agent-core": "18.8.2",
52
- "@f5xc-salesdemos/pi-ai": "18.8.2",
53
- "@f5xc-salesdemos/pi-natives": "18.8.2",
54
- "@f5xc-salesdemos/pi-tui": "18.8.2",
55
- "@f5xc-salesdemos/pi-utils": "18.8.2",
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,
@@ -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.8.2",
21
- "commit": "b67646bd5096b169019d794fd0f894f2969696d4",
22
- "shortCommit": "b67646b",
20
+ "version": "18.9.0",
21
+ "commit": "ed061104e575b3004bd0f578577b9e30d1f294a5",
22
+ "shortCommit": "ed06110",
23
23
  "branch": "main",
24
- "tag": "v18.8.2",
25
- "commitDate": "2026-04-22T19:12:35Z",
26
- "buildDate": "2026-04-22T19:48:35.804Z",
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/b67646bd5096b169019d794fd0f894f2969696d4",
32
- "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.8.2"
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 { AutocompleteProvider, SlashCommand } from "@f5xc-salesdemos/pi-tui";
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(