@code-yeongyu/senpi 2026.5.29-4 → 2026.6.2

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.
Files changed (121) hide show
  1. package/CHANGELOG.md +41 -1
  2. package/README.md +8 -2
  3. package/dist/cli/args.d.ts +1 -0
  4. package/dist/cli/args.d.ts.map +1 -1
  5. package/dist/cli/args.js +13 -0
  6. package/dist/cli/args.js.map +1 -1
  7. package/dist/config.d.ts.map +1 -1
  8. package/dist/config.js +9 -1
  9. package/dist/config.js.map +1 -1
  10. package/dist/core/agent-session.d.ts +5 -1
  11. package/dist/core/agent-session.d.ts.map +1 -1
  12. package/dist/core/agent-session.js +18 -2
  13. package/dist/core/agent-session.js.map +1 -1
  14. package/dist/core/compaction/branch-summarization.d.ts +3 -1
  15. package/dist/core/compaction/branch-summarization.d.ts.map +1 -1
  16. package/dist/core/compaction/branch-summarization.js +9 -3
  17. package/dist/core/compaction/branch-summarization.js.map +1 -1
  18. package/dist/core/extensions/index.d.ts +1 -1
  19. package/dist/core/extensions/index.d.ts.map +1 -1
  20. package/dist/core/extensions/index.js.map +1 -1
  21. package/dist/core/extensions/runner.d.ts +4 -2
  22. package/dist/core/extensions/runner.d.ts.map +1 -1
  23. package/dist/core/extensions/runner.js +13 -1
  24. package/dist/core/extensions/runner.js.map +1 -1
  25. package/dist/core/extensions/types.d.ts +7 -1
  26. package/dist/core/extensions/types.d.ts.map +1 -1
  27. package/dist/core/extensions/types.js.map +1 -1
  28. package/dist/core/footer-data-provider.d.ts +2 -0
  29. package/dist/core/footer-data-provider.d.ts.map +1 -1
  30. package/dist/core/footer-data-provider.js +29 -1
  31. package/dist/core/footer-data-provider.js.map +1 -1
  32. package/dist/core/model-resolver.d.ts.map +1 -1
  33. package/dist/core/model-resolver.js +1 -0
  34. package/dist/core/model-resolver.js.map +1 -1
  35. package/dist/core/provider-attribution.d.ts +4 -0
  36. package/dist/core/provider-attribution.d.ts.map +1 -0
  37. package/dist/core/provider-attribution.js +73 -0
  38. package/dist/core/provider-attribution.js.map +1 -0
  39. package/dist/core/provider-display-names.d.ts.map +1 -1
  40. package/dist/core/provider-display-names.js +1 -0
  41. package/dist/core/provider-display-names.js.map +1 -1
  42. package/dist/core/sdk.d.ts.map +1 -1
  43. package/dist/core/sdk.js +8 -34
  44. package/dist/core/sdk.js.map +1 -1
  45. package/dist/core/session-manager.d.ts.map +1 -1
  46. package/dist/core/session-manager.js +92 -68
  47. package/dist/core/session-manager.js.map +1 -1
  48. package/dist/core/tools/edit.d.ts.map +1 -1
  49. package/dist/core/tools/edit.js +7 -10
  50. package/dist/core/tools/edit.js.map +1 -1
  51. package/dist/core/tools/find.d.ts.map +1 -1
  52. package/dist/core/tools/find.js.map +1 -1
  53. package/dist/core/tools/grep.d.ts.map +1 -1
  54. package/dist/core/tools/grep.js.map +1 -1
  55. package/dist/core/tools/ls.d.ts.map +1 -1
  56. package/dist/core/tools/ls.js +5 -7
  57. package/dist/core/tools/ls.js.map +1 -1
  58. package/dist/core/tools/read.d.ts.map +1 -1
  59. package/dist/core/tools/read.js +6 -7
  60. package/dist/core/tools/read.js.map +1 -1
  61. package/dist/core/tools/render-utils.d.ts +5 -2
  62. package/dist/core/tools/render-utils.d.ts.map +1 -1
  63. package/dist/core/tools/render-utils.js +17 -1
  64. package/dist/core/tools/render-utils.js.map +1 -1
  65. package/dist/core/tools/write.d.ts.map +1 -1
  66. package/dist/core/tools/write.js +5 -6
  67. package/dist/core/tools/write.js.map +1 -1
  68. package/dist/index.d.ts +2 -0
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +2 -0
  71. package/dist/index.js.map +1 -1
  72. package/dist/main.d.ts.map +1 -1
  73. package/dist/main.js +8 -0
  74. package/dist/main.js.map +1 -1
  75. package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  76. package/dist/modes/interactive/components/tool-execution.js +25 -1
  77. package/dist/modes/interactive/components/tool-execution.js.map +1 -1
  78. package/dist/modes/interactive/interactive-mode.d.ts +3 -0
  79. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  80. package/dist/modes/interactive/interactive-mode.js +39 -0
  81. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  82. package/dist/modes/print-mode.d.ts.map +1 -1
  83. package/dist/modes/print-mode.js +1 -0
  84. package/dist/modes/print-mode.js.map +1 -1
  85. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  86. package/dist/modes/rpc/rpc-mode.js +1 -0
  87. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  88. package/docs/extensions.md +35 -11
  89. package/docs/providers.md +2 -0
  90. package/docs/quickstart.md +1 -0
  91. package/docs/rpc.md +3 -2
  92. package/docs/session-format.md +1 -1
  93. package/docs/sessions.md +8 -0
  94. package/docs/settings.md +1 -1
  95. package/docs/terminal-setup.md +2 -0
  96. package/docs/tui.md +12 -3
  97. package/docs/usage.md +6 -1
  98. package/examples/extensions/custom-header.ts +1 -1
  99. package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
  100. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  101. package/examples/extensions/custom-provider-gitlab-duo/index.ts +53 -2
  102. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  103. package/examples/extensions/doom-overlay/index.ts +1 -1
  104. package/examples/extensions/handoff.ts +1 -1
  105. package/examples/extensions/interactive-shell.ts +1 -1
  106. package/examples/extensions/overlay-qa-tests.ts +152 -81
  107. package/examples/extensions/qna.ts +1 -1
  108. package/examples/extensions/question.ts +1 -1
  109. package/examples/extensions/questionnaire.ts +1 -1
  110. package/examples/extensions/sandbox/package-lock.json +2 -2
  111. package/examples/extensions/sandbox/package.json +1 -1
  112. package/examples/extensions/snake.ts +1 -1
  113. package/examples/extensions/space-invaders.ts +1 -1
  114. package/examples/extensions/summarize.ts +1 -1
  115. package/examples/extensions/tic-tac-toe.ts +1 -1
  116. package/examples/extensions/todo.ts +1 -1
  117. package/examples/extensions/tools.ts +5 -0
  118. package/examples/extensions/with-deps/package-lock.json +2 -2
  119. package/examples/extensions/with-deps/package.json +1 -1
  120. package/npm-shrinkwrap.json +12 -12
  121. package/package.json +4 -4
@@ -282,7 +282,7 @@ Set `label` to `undefined` to clear a label.
282
282
 
283
283
  ### SessionInfoEntry
284
284
 
285
- Session metadata (e.g., user-defined display name). Set via `/name` command or `pi.setSessionName()` in extensions.
285
+ Session metadata (e.g., user-defined display name). Set via `/name`, `--name` / `-n`, or `pi.setSessionName()` in extensions.
286
286
 
287
287
  ```json
288
288
  {"type":"session_info","id":"k1l2m3n4","parentId":"j0k1l2m3","timestamp":"2024-12-03T14:35:00.000Z","name":"Refactor auth module"}
package/docs/sessions.md CHANGED
@@ -10,6 +10,7 @@ Sessions auto-save to `~/.pi/agent/sessions/`, organized by working directory. E
10
10
  pi -c # Continue most recent session
11
11
  pi -r # Browse and select from past sessions
12
12
  pi --no-session # Ephemeral mode; do not save
13
+ pi --name "my task" # Set session display name at startup
13
14
  pi --session <path|id> # Use a specific session file or partial session ID
14
15
  pi --fork <path|id> # Fork a session file or partial session ID into a new session
15
16
  ```
@@ -56,6 +57,13 @@ Use `/name <name>` to set a human-readable session name:
56
57
  /name Refactor auth module
57
58
  ```
58
59
 
60
+ Set the name at startup with `--name` or `-n`:
61
+
62
+ ```bash
63
+ pi --name "Refactor auth module"
64
+ pi --name "CI audit" -p "Review this build failure"
65
+ ```
66
+
59
67
  Named sessions are easier to find in `/resume` and `pi -r`.
60
68
 
61
69
  ## Branching with `/tree`
package/docs/settings.md CHANGED
@@ -60,7 +60,7 @@ When this value is anything other than `"auto"`, it overrides any model-level `p
60
60
  | `treeFilterMode` | string | `"default"` | Default filter for `/tree`: `"default"`, `"no-tools"`, `"user-only"`, `"labeled-only"`, `"all"` |
61
61
  | `editorPaddingX` | number | `0` | Horizontal padding for input editor (0-3) |
62
62
  | `autocompleteMaxVisible` | number | `5` | Max visible items in autocomplete dropdown (3-20) |
63
- | `showHardwareCursor` | boolean | `false` | Show terminal cursor |
63
+ | `showHardwareCursor` | boolean | `false` | Show the terminal cursor while TUI positions it for IME support |
64
64
 
65
65
  ### Telemetry and update checks
66
66
 
@@ -49,6 +49,8 @@ config.enable_kitty_keyboard = true
49
49
  return config
50
50
  ```
51
51
 
52
+ On WSL, WezTerm may require a visible hardware cursor for IME candidate window positioning. If CJK IME candidates do not follow the text cursor, set `PI_HARDWARE_CURSOR=1` before running pi or set `showHardwareCursor` to `true` in settings.
53
+
52
54
  ## VS Code (Integrated Terminal)
53
55
 
54
56
  `keybindings.json` locations:
package/docs/tui.md CHANGED
@@ -50,9 +50,9 @@ When a `Focusable` component has focus, TUI:
50
50
  1. Sets `focused = true` on the component
51
51
  2. Scans rendered output for `CURSOR_MARKER` (a zero-width APC escape sequence)
52
52
  3. Positions the hardware terminal cursor at that location
53
- 4. Shows the hardware cursor
53
+ 4. Shows the hardware cursor only when `showHardwareCursor` is enabled
54
54
 
55
- This enables IME candidate windows to appear at the correct position for CJK input methods. The `Editor` and `Input` built-in components already implement this interface.
55
+ The cursor remains hidden by default. This keeps the fake cursor rendering, while still positioning the hardware cursor for terminals that track IME candidate windows with hidden cursors. Some terminals require a visible hardware cursor for IME positioning; enable it with `showHardwareCursor`, `setShowHardwareCursor(true)`, or `PI_HARDWARE_CURSOR=1`. The `Editor` and `Input` built-in components already implement this interface.
56
56
 
57
57
  ### Container Components with Embedded Inputs
58
58
 
@@ -145,8 +145,11 @@ const result = await ctx.ui.custom<string | null>(
145
145
  // Responsive: hide on narrow terminals
146
146
  visible: (termWidth, termHeight) => termWidth >= 80,
147
147
  },
148
- // Get handle for programmatic visibility control
148
+ // Get handle for programmatic focus and visibility control
149
149
  onHandle: (handle) => {
150
+ // handle.focus() - focus this overlay and bring it to the visual front
151
+ // handle.unfocus() - release input to normal fallback
152
+ // handle.unfocus({ target }) - release input to a specific component or null
150
153
  // handle.setHidden(true/false) - toggle visibility
151
154
  // handle.hide() - permanently remove
152
155
  },
@@ -154,6 +157,12 @@ const result = await ctx.ui.custom<string | null>(
154
157
  );
155
158
  ```
156
159
 
160
+ ### Overlay Focus
161
+
162
+ A focused visible overlay keeps input ownership across temporary non-overlay UI. If an overlay opens another `ctx.ui.custom()` component without `{ overlay: true }`, that replacement UI receives input while it is active; when it closes, the focused overlay can reclaim input.
163
+
164
+ Use `handle.unfocus()` when a visible overlay should stop owning input and let TUI fall back to another visible capturing overlay or the previous focus target. Use `handle.unfocus({ target })` when a specific component should receive input while the overlay stays visible. Passing `{ target: null }` intentionally leaves no focused component until focus is set again.
165
+
157
166
  ### Overlay Lifecycle
158
167
 
159
168
  Overlay components are disposed when closed. Don't reuse references - create fresh instances:
package/docs/usage.md CHANGED
@@ -76,6 +76,7 @@ Sessions are saved automatically to `~/.pi/agent/sessions/`, organized by workin
76
76
  pi -c # Continue most recent session
77
77
  pi -r # Browse and select a session
78
78
  pi --no-session # Ephemeral mode; do not save
79
+ pi --name "my task" # Set session display name at startup
79
80
  pi --session <path|id> # Use a specific session file or session ID
80
81
  pi --fork <path|id> # Fork a session into a new session file
81
82
  ```
@@ -178,6 +179,7 @@ cat README.md | pi -p "Summarize this text"
178
179
  | `--fork <path\|id>` | Fork a session file or partial UUID into a new session |
179
180
  | `--session-dir <dir>` | Custom session storage directory |
180
181
  | `--no-session` | Ephemeral mode; do not save |
182
+ | `--name <name>`, `-n <name>` | Set session display name at startup |
181
183
 
182
184
  ### Tool Options
183
185
 
@@ -242,6 +244,9 @@ pi -p "Summarize this codebase"
242
244
  # Non-interactive with piped stdin
243
245
  cat README.md | pi -p "Summarize this text"
244
246
 
247
+ # Named one-shot session
248
+ pi --name "release audit" -p "Audit this repository"
249
+
245
250
  # Different model
246
251
  pi --provider openai --model gpt-4o "Help me refactor"
247
252
 
@@ -270,7 +275,7 @@ pi --exclude-tools ask_question
270
275
  | `PI_PACKAGE_DIR` | Override package directory, useful for Nix/Guix store paths |
271
276
  | `PI_OFFLINE` | Disable startup network operations, including update checks, package update checks, and install/update telemetry |
272
277
  | `PI_SKIP_VERSION_CHECK` | Skip the Pi version update check at startup. This prevents the `pi.dev` latest-version request |
273
- | `PI_TELEMETRY` | Override install/update telemetry: `1`/`true`/`yes` or `0`/`false`/`no`. This does not disable update checks |
278
+ | `PI_TELEMETRY` | Override install/update telemetry and provider attribution headers: `1`/`true`/`yes` or `0`/`false`/`no`. This does not disable update checks |
274
279
  | `PI_CACHE_RETENTION` | Set to `long` for extended prompt cache where supported |
275
280
  | `VISUAL`, `EDITOR` | External editor for Ctrl+G |
276
281
 
@@ -47,7 +47,7 @@ function getPiMascot(theme: Theme): string[] {
47
47
  export default function (pi: ExtensionAPI) {
48
48
  // Set custom header immediately on load (if UI is available)
49
49
  pi.on("session_start", async (_event, ctx) => {
50
- if (ctx.hasUI) {
50
+ if (ctx.mode === "tui") {
51
51
  ctx.ui.setHeader((_tui, theme) => {
52
52
  return {
53
53
  render(_width: number): string[] {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pi-extension-custom-provider",
3
- "version": "0.77.0",
3
+ "version": "0.78.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pi-extension-custom-provider",
9
- "version": "0.77.0",
9
+ "version": "0.78.0",
10
10
  "dependencies": {
11
11
  "@anthropic-ai/sdk": "^0.52.0"
12
12
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-extension-custom-provider-anthropic",
3
3
  "private": true,
4
- "version": "0.77.0",
4
+ "version": "0.78.0",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "clean": "echo 'nothing to clean'",
@@ -21,6 +21,7 @@ import {
21
21
  type SimpleStreamOptions,
22
22
  streamSimpleAnthropic,
23
23
  streamSimpleOpenAIResponses,
24
+ type ThinkingLevelMap,
24
25
  } from "@earendil-works/pi-ai";
25
26
 
26
27
  // =============================================================================
@@ -49,6 +50,7 @@ interface GitLabModel {
49
50
  backend: Backend;
50
51
  baseUrl: string;
51
52
  reasoning: boolean;
53
+ thinkingLevelMap?: ThinkingLevelMap;
52
54
  input: ("text" | "image")[];
53
55
  cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
54
56
  contextWindow: number;
@@ -57,12 +59,37 @@ interface GitLabModel {
57
59
 
58
60
  export const MODELS: GitLabModel[] = [
59
61
  // Anthropic
62
+ {
63
+ id: "claude-opus-4-8",
64
+ name: "Claude Opus 4.8",
65
+ backend: "anthropic",
66
+ baseUrl: ANTHROPIC_PROXY_URL,
67
+ reasoning: true,
68
+ thinkingLevelMap: { xhigh: "max" },
69
+ input: ["text", "image"],
70
+ cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
71
+ contextWindow: 1000000,
72
+ maxTokens: 128000,
73
+ },
74
+ {
75
+ id: "claude-sonnet-4-6",
76
+ name: "Claude Sonnet 4.6",
77
+ backend: "anthropic",
78
+ baseUrl: ANTHROPIC_PROXY_URL,
79
+ reasoning: true,
80
+ thinkingLevelMap: { xhigh: "max" },
81
+ input: ["text", "image"],
82
+ cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
83
+ contextWindow: 1000000,
84
+ maxTokens: 64000,
85
+ },
60
86
  {
61
87
  id: "claude-opus-4-5-20251101",
62
88
  name: "Claude Opus 4.5",
63
89
  backend: "anthropic",
64
90
  baseUrl: ANTHROPIC_PROXY_URL,
65
91
  reasoning: true,
92
+ thinkingLevelMap: { xhigh: "max" },
66
93
  input: ["text", "image"],
67
94
  cost: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
68
95
  contextWindow: 200000,
@@ -74,6 +101,7 @@ export const MODELS: GitLabModel[] = [
74
101
  backend: "anthropic",
75
102
  baseUrl: ANTHROPIC_PROXY_URL,
76
103
  reasoning: true,
104
+ thinkingLevelMap: { xhigh: "max" },
77
105
  input: ["text", "image"],
78
106
  cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
79
107
  contextWindow: 200000,
@@ -85,12 +113,24 @@ export const MODELS: GitLabModel[] = [
85
113
  backend: "anthropic",
86
114
  baseUrl: ANTHROPIC_PROXY_URL,
87
115
  reasoning: true,
116
+ thinkingLevelMap: { xhigh: "max" },
88
117
  input: ["text", "image"],
89
118
  cost: { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
90
119
  contextWindow: 200000,
91
120
  maxTokens: 8192,
92
121
  },
93
122
  // OpenAI (all use Responses API)
123
+ {
124
+ id: "gpt-5.5-2026-04-23",
125
+ name: "GPT-5.5",
126
+ backend: "openai",
127
+ baseUrl: OPENAI_PROXY_URL,
128
+ reasoning: true,
129
+ input: ["text", "image"],
130
+ cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 },
131
+ contextWindow: 272000,
132
+ maxTokens: 128000,
133
+ },
94
134
  {
95
135
  id: "gpt-5.1-2025-11-13",
96
136
  name: "GPT-5.1",
@@ -285,7 +325,17 @@ export function streamGitLabDuo(
285
325
 
286
326
  const innerStream =
287
327
  cfg.backend === "anthropic"
288
- ? streamSimpleAnthropic(modelWithBaseUrl as Model<"anthropic-messages">, context, streamOptions)
328
+ ? streamSimpleAnthropic(
329
+ {
330
+ ...(modelWithBaseUrl as Model<"anthropic-messages">),
331
+ compat: {
332
+ ...(modelWithBaseUrl as Model<"anthropic-messages">).compat,
333
+ forceAdaptiveThinking: true,
334
+ },
335
+ },
336
+ context,
337
+ streamOptions,
338
+ )
289
339
  : streamSimpleOpenAIResponses(modelWithBaseUrl as Model<"openai-responses">, context, streamOptions);
290
340
 
291
341
  for await (const event of innerStream) stream.push(event);
@@ -329,10 +379,11 @@ export default function (pi: ExtensionAPI) {
329
379
  baseUrl: AI_GATEWAY_URL,
330
380
  apiKey: "$GITLAB_TOKEN",
331
381
  api: "gitlab-duo-api",
332
- models: MODELS.map(({ id, name, reasoning, input, cost, contextWindow, maxTokens }) => ({
382
+ models: MODELS.map(({ id, name, reasoning, thinkingLevelMap, input, cost, contextWindow, maxTokens }) => ({
333
383
  id,
334
384
  name,
335
385
  reasoning,
386
+ thinkingLevelMap,
336
387
  input,
337
388
  cost,
338
389
  contextWindow,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-extension-custom-provider-gitlab-duo",
3
3
  "private": true,
4
- "version": "0.77.0",
4
+ "version": "0.78.0",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "clean": "echo 'nothing to clean'",
@@ -23,7 +23,7 @@ export default function (pi: ExtensionAPI) {
23
23
  description: "Play DOOM as an overlay. Q to pause and exit.",
24
24
 
25
25
  handler: async (args, ctx) => {
26
- if (!ctx.hasUI) {
26
+ if (ctx.mode !== "tui") {
27
27
  ctx.ui.notify("DOOM requires interactive mode", "error");
28
28
  return;
29
29
  }
@@ -81,7 +81,7 @@ export default function (pi: ExtensionAPI) {
81
81
  pi.registerCommand("handoff", {
82
82
  description: "Transfer context to a new focused session",
83
83
  handler: async (args, ctx) => {
84
- if (!ctx.hasUI) {
84
+ if (ctx.mode !== "tui") {
85
85
  ctx.ui.notify("handoff requires interactive mode", "error");
86
86
  return;
87
87
  }
@@ -146,7 +146,7 @@ export default function (pi: ExtensionAPI) {
146
146
  }
147
147
 
148
148
  // No UI available (print mode, RPC, etc.)
149
- if (!ctx.hasUI) {
149
+ if (ctx.mode !== "tui") {
150
150
  return {
151
151
  result: { output: "(interactive commands require TUI)", exitCode: 1, cancelled: false, truncated: false },
152
152
  };
@@ -15,13 +15,13 @@
15
15
  * /overlay-sidepanel - Responsive sidepanel (hides when terminal < 100 cols)
16
16
  * /overlay-toggle - Toggle visibility demo (demonstrates OverlayHandle.setHidden)
17
17
  * /overlay-passive - Non-capturing overlay demo (passive info panel alongside active overlay)
18
- * /overlay-focus - Focus cycling and rendering order with non-capturing overlays
18
+ * /overlay-focus - Focus cycling, input routing, dismissal, and rendering order with overlays
19
19
  * /overlay-streaming - Multiple input panels with simulated streaming (Tab to cycle focus)
20
20
  */
21
21
 
22
22
  import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@code-yeongyu/senpi";
23
23
  import type { Component, OverlayAnchor, OverlayHandle, OverlayOptions, TUI } from "@earendil-works/pi-tui";
24
- import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
24
+ import { Input, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
25
25
  import { spawn } from "child_process";
26
26
 
27
27
  // Global handle for toggle demo (in real code, use a more elegant pattern)
@@ -272,9 +272,9 @@ export default function (pi: ExtensionAPI) {
272
272
  },
273
273
  });
274
274
 
275
- // Focus cycling demo - demonstrates focus(), unfocus(), isFocused() and rendering order
275
+ // Focus cycling demo - demonstrates focus(), input routing, per-panel dismissal, and rendering order
276
276
  pi.registerCommand("overlay-focus", {
277
- description: "Test focus cycling and rendering order with non-capturing overlays",
277
+ description: "Test focus cycling, input routing, dismissal, and rendering order with overlays",
278
278
  handler: async (_args: string, ctx: ExtensionCommandContext) => {
279
279
  ctx.ui.setEditorText("");
280
280
  await ctx.ui.custom<void>((tui, theme, _kb, done) => new FocusDemoController(tui, theme, done), {
@@ -1031,57 +1031,66 @@ class TimerPanel extends BaseOverlay {
1031
1031
 
1032
1032
  // === Focus cycling demo ===
1033
1033
 
1034
+ type FocusPanelColor = "error" | "success" | "accent";
1035
+ type FocusPanelConfig = { label: string; color: FocusPanelColor; options: OverlayOptions };
1036
+ type FocusPanelEntry = { panel: FocusPanel; handle: OverlayHandle };
1037
+
1038
+ const FOCUS_PANEL_CONFIGS = [
1039
+ { label: "Alpha", color: "error", options: { row: 2, col: 4, width: 34 } },
1040
+ { label: "Beta", color: "success", options: { row: 5, col: 28, width: 34 } },
1041
+ { label: "Gamma", color: "accent", options: { row: 8, col: 52, width: 34 } },
1042
+ ] satisfies FocusPanelConfig[];
1043
+
1034
1044
  class FocusDemoController extends BaseOverlay {
1035
- private tui: TUI;
1036
- private panels: FocusPanel[] = [];
1037
- private handles: OverlayHandle[] = [];
1038
- private focusIndex = -1;
1039
- private done: () => void;
1045
+ private readonly tui: TUI;
1046
+ private entries: FocusPanelEntry[] = [];
1047
+ private readonly done: () => void;
1048
+ private closed = false;
1040
1049
 
1041
1050
  constructor(tui: TUI, theme: Theme, done: () => void) {
1042
1051
  super(theme);
1043
1052
  this.tui = tui;
1044
1053
  this.done = done;
1045
- const colors = ["error", "success", "accent"] as const;
1046
- const labels = ["Alpha", "Beta", "Gamma"];
1047
1054
 
1048
- for (let i = 0; i < 3; i++) {
1049
- const panel = new FocusPanel(
1050
- theme,
1051
- labels[i]!,
1052
- colors[i]!,
1053
- () => this.cycleFocus(),
1054
- () => this.close(),
1055
- );
1056
- const handle = this.tui.showOverlay(panel, {
1057
- nonCapturing: true,
1058
- row: 2,
1059
- col: 5 + i * 6,
1060
- width: 28,
1061
- });
1062
- panel.handle = handle;
1063
- this.panels.push(panel);
1064
- this.handles.push(handle);
1055
+ for (const config of FOCUS_PANEL_CONFIGS) {
1056
+ const panel = new FocusPanel({ theme, config, controller: this });
1057
+ const handle = this.tui.showOverlay(panel, { nonCapturing: true, ...config.options });
1058
+ this.entries.push({ panel, handle });
1065
1059
  }
1060
+
1061
+ this.focusFirstOpenPanel();
1066
1062
  }
1067
1063
 
1068
- private cycleFocus(): void {
1069
- if (this.focusIndex >= 0 && this.focusIndex < this.handles.length) {
1070
- this.handles[this.focusIndex]!.unfocus();
1071
- }
1072
- this.focusIndex++;
1073
- if (this.focusIndex >= this.handles.length) {
1074
- this.focusIndex = -1;
1075
- } else {
1076
- this.handles[this.focusIndex]!.focus();
1064
+ focusNext(current: FocusPanel, direction: 1 | -1 = 1): void {
1065
+ const openEntries = this.openEntries();
1066
+ const currentOpenPosition = openEntries.findIndex((entry) => entry.panel === current);
1067
+ if (currentOpenPosition === -1) throw new Error(`Panel ${current.label} is not open`);
1068
+ const nextOpenPosition = (currentOpenPosition + direction + openEntries.length) % openEntries.length;
1069
+ this.focusEntryAt(openEntries, nextOpenPosition);
1070
+ }
1071
+
1072
+ dismiss(panel: FocusPanel): void {
1073
+ const openEntries = this.openEntries();
1074
+ const currentOpenPosition = openEntries.findIndex((candidate) => candidate.panel === panel);
1075
+ if (currentOpenPosition === -1) return;
1076
+ const entry = openEntries[currentOpenPosition];
1077
+ if (!entry) throw new Error(`Invalid focus panel index ${currentOpenPosition}`);
1078
+ const remainingEntries = openEntries.filter((candidate) => candidate.panel !== panel);
1079
+
1080
+ entry.panel.closed = true;
1081
+ entry.handle.hide();
1082
+ if (remainingEntries.length === 0) {
1083
+ this.close();
1084
+ return;
1077
1085
  }
1078
- this.tui.requestRender();
1086
+
1087
+ this.focusEntryAt(remainingEntries, currentOpenPosition % remainingEntries.length);
1079
1088
  }
1080
1089
 
1081
- private close(): void {
1082
- for (const handle of this.handles) handle.hide();
1083
- this.handles = [];
1084
- this.panels = [];
1090
+ close(): void {
1091
+ if (this.closed) return;
1092
+ this.closed = true;
1093
+ this.hidePanels();
1085
1094
  this.done();
1086
1095
  }
1087
1096
 
@@ -1089,86 +1098,148 @@ class FocusDemoController extends BaseOverlay {
1089
1098
  if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
1090
1099
  this.close();
1091
1100
  } else if (matchesKey(data, "tab")) {
1092
- this.cycleFocus();
1101
+ this.focusFirstOpenPanel();
1093
1102
  }
1094
1103
  }
1095
1104
 
1096
1105
  render(width: number): string[] {
1097
1106
  const th = this.theme;
1098
- const focused = this.focusIndex === -1 ? "Controller" : (this.panels[this.focusIndex]?.label ?? "?");
1107
+ const focused = this.entries.find((entry) => entry.handle.isFocused())?.panel.label ?? "Controller";
1099
1108
  return this.box(
1100
1109
  [
1101
1110
  "",
1102
1111
  ` Current focus: ${th.fg("accent", focused)}`,
1103
1112
  "",
1104
1113
  " Three overlapping panels above are",
1105
- ` all ${th.fg("accent", "nonCapturing")}. Press Tab to`,
1106
- " cycle focus() between them.",
1114
+ ` ${th.fg("accent", "nonCapturing")} overlays controlled with`,
1115
+ " raw OverlayHandle.focus()/hide().",
1107
1116
  "",
1108
- " Focused panel renders on top",
1109
- " (focus-based rendering order).",
1117
+ " Type in the focused panel's input.",
1118
+ " Focused panel renders on top.",
1110
1119
  "",
1111
- th.fg("dim", " Tab = cycle focus | Esc = close"),
1120
+ th.fg("dim", " Tab/Shift+Tab = cycle panels"),
1121
+ th.fg("dim", " Esc/Ctrl+D = dismiss panel"),
1122
+ th.fg("dim", " Ctrl+C = close all"),
1112
1123
  "",
1113
1124
  ],
1114
1125
  width,
1115
- "Focus Demo",
1126
+ "Focus + Input Demo",
1116
1127
  );
1117
1128
  }
1118
1129
 
1119
1130
  override dispose(): void {
1120
- for (const handle of this.handles) handle.hide();
1131
+ if (this.closed) return;
1132
+ this.closed = true;
1133
+ this.hidePanels();
1134
+ }
1135
+
1136
+ private focusFirstOpenPanel(): void {
1137
+ const firstOpen = this.openEntries()[0];
1138
+ if (firstOpen) {
1139
+ firstOpen.handle.focus();
1140
+ this.tui.requestRender();
1141
+ }
1142
+ }
1143
+
1144
+ private focusEntryAt(entries: FocusPanelEntry[], index: number): void {
1145
+ const entry = entries[index];
1146
+ if (!entry) throw new Error(`Invalid focus panel index ${index}`);
1147
+ entry.handle.focus();
1148
+ this.tui.requestRender();
1149
+ }
1150
+
1151
+ private hidePanels(): void {
1152
+ for (const entry of this.entries) {
1153
+ if (!entry.panel.closed) {
1154
+ entry.panel.closed = true;
1155
+ entry.handle.hide();
1156
+ }
1157
+ }
1158
+ this.entries = [];
1159
+ }
1160
+
1161
+ private openEntries(): FocusPanelEntry[] {
1162
+ return this.entries.filter((entry) => !entry.panel.closed);
1121
1163
  }
1122
1164
  }
1123
1165
 
1124
1166
  class FocusPanel extends BaseOverlay {
1125
- handle: OverlayHandle | null = null;
1167
+ focused = false;
1168
+ closed = false;
1126
1169
  readonly label: string;
1127
- private color: "error" | "success" | "accent";
1128
- private onTab: () => void;
1129
- private onClose: () => void;
1130
-
1131
- constructor(
1132
- theme: Theme,
1133
- label: string,
1134
- color: "error" | "success" | "accent",
1135
- onTab: () => void,
1136
- onClose: () => void,
1137
- ) {
1170
+ private readonly color: FocusPanelColor;
1171
+ private readonly controller: FocusDemoController;
1172
+ private readonly input = new Input();
1173
+ private inputs: string[] = [];
1174
+
1175
+ constructor({
1176
+ theme,
1177
+ config,
1178
+ controller,
1179
+ }: {
1180
+ theme: Theme;
1181
+ config: FocusPanelConfig;
1182
+ controller: FocusDemoController;
1183
+ }) {
1138
1184
  super(theme);
1139
- this.label = label;
1140
- this.color = color;
1141
- this.onTab = onTab;
1142
- this.onClose = onClose;
1185
+ this.label = config.label;
1186
+ this.color = config.color;
1187
+ this.controller = controller;
1143
1188
  }
1144
1189
 
1145
1190
  handleInput(data: string): void {
1146
1191
  if (matchesKey(data, "tab")) {
1147
- this.onTab();
1148
- } else if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
1149
- this.onClose();
1192
+ this.controller.focusNext(this);
1193
+ } else if (matchesKey(data, "shift+tab")) {
1194
+ this.controller.focusNext(this, -1);
1195
+ } else if (matchesKey(data, "escape") || matchesKey(data, "ctrl+d")) {
1196
+ this.controller.dismiss(this);
1197
+ } else if (matchesKey(data, "ctrl+c")) {
1198
+ this.controller.close();
1199
+ } else if (matchesKey(data, "return")) {
1200
+ this.inputs.push("Enter");
1201
+ } else if (matchesKey(data, "up")) {
1202
+ this.inputs.push("↑");
1203
+ } else if (matchesKey(data, "down")) {
1204
+ this.inputs.push("↓");
1205
+ } else if (matchesKey(data, "left")) {
1206
+ this.input.handleInput(data);
1207
+ this.inputs.push("←");
1208
+ } else if (matchesKey(data, "right")) {
1209
+ this.input.handleInput(data);
1210
+ this.inputs.push("→");
1211
+ } else if (matchesKey(data, "backspace")) {
1212
+ this.input.handleInput(data);
1213
+ this.inputs.push("Backspace");
1214
+ } else {
1215
+ this.input.handleInput(data);
1216
+ this.inputs.push(JSON.stringify(data));
1150
1217
  }
1151
1218
  }
1152
1219
 
1153
1220
  render(width: number): string[] {
1154
1221
  const th = this.theme;
1155
- const focused = this.handle?.isFocused() ?? false;
1156
1222
  const innerW = Math.max(1, width - 2);
1157
- const border = (c: string) => th.fg(this.color, c);
1223
+ const border = (c: string) => th.fg(this.focused ? this.color : "dim", c);
1158
1224
  const padLine = (s: string) => truncateToWidth(s, innerW, "...", true);
1225
+ const recent = this.inputs.length === 0 ? "(none)" : this.inputs.slice(-6).join(" ");
1159
1226
  const lines: string[] = [];
1160
1227
 
1228
+ this.input.focused = this.focused;
1229
+ const [inputLine = ""] = this.input.render(Math.max(1, innerW - 8));
1161
1230
  lines.push(border(`╭${"─".repeat(innerW)}╮`));
1162
- lines.push(border("│") + padLine(` ${th.fg("accent", this.label)}`) + border("│"));
1163
- lines.push(border("│") + padLine("") + border("│"));
1164
- if (focused) {
1165
- lines.push(border("│") + padLine(th.fg("success", "FOCUSED")) + border(""));
1166
- lines.push(border("│") + padLine(th.fg("dim", " (receiving input)")) + border("│"));
1167
- } else {
1168
- lines.push(border("│") + padLine(th.fg("dim", " ○ unfocused")) + border("│"));
1169
- lines.push(border("│") + padLine(th.fg("dim", " (passive)")) + border("│"));
1170
- }
1231
+ lines.push(
1232
+ border("│") +
1233
+ padLine(
1234
+ ` ${th.fg(this.color, this.label)} ${this.focused ? th.fg("success", "FOCUSED") : th.fg("dim", "visible")}`,
1235
+ ) +
1236
+ border("│"),
1237
+ );
1171
1238
  lines.push(border("│") + padLine("") + border("│"));
1239
+ lines.push(border("│") + padLine(` Input: ${inputLine}`) + border("│"));
1240
+ lines.push(border("│") + padLine(` Keys: ${recent}`) + border("│"));
1241
+ lines.push(border("│") + padLine(th.fg("dim", " Tab/Shift+Tab focus")) + border("│"));
1242
+ lines.push(border("│") + padLine(th.fg("dim", " Esc/Ctrl+D dismiss")) + border("│"));
1172
1243
  lines.push(border(`╰${"─".repeat(innerW)}╯`));
1173
1244
 
1174
1245
  return lines;
@@ -31,7 +31,7 @@ export default function (pi: ExtensionAPI) {
31
31
  pi.registerCommand("qna", {
32
32
  description: "Extract questions from last assistant message into editor",
33
33
  handler: async (_args, ctx) => {
34
- if (!ctx.hasUI) {
34
+ if (ctx.mode !== "tui") {
35
35
  ctx.ui.notify("qna requires interactive mode", "error");
36
36
  return;
37
37
  }
@@ -41,7 +41,7 @@ export default function question(pi: ExtensionAPI) {
41
41
  parameters: QuestionParams,
42
42
 
43
43
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
44
- if (!ctx.hasUI) {
44
+ if (ctx.mode !== "tui") {
45
45
  return {
46
46
  content: [{ type: "text", text: "Error: UI not available (running in non-interactive mode)" }],
47
47
  details: {