omp-claude-tui 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jack-01041202
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,142 @@
1
- # Temporary Holding Version
1
+ # omp-claude-tui
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Claude Code-style transcript for [omp](https://omp.sh): `● Tool(arg)` cards with a
4
+ `⎿` result gutter, a fullscreen scroll mode with omp's own input docked at the
5
+ bottom, and in-app drag-to-copy.
6
+
7
+ > **Compatibility:** tested on omp **18.16.0** only. omp has no public API for
8
+ > this, so the plugin hooks omp internals (see [How it works / risk](#how-it-works--risk)).
9
+ > On another omp version a feature may stop working; it then falls back to omp's
10
+ > own look instead of crashing, and `/cc-tui status` shows what is unavailable.
11
+
12
+ ```
13
+ ● Bash(seq 1 10)
14
+ ⎿ 1
15
+ 2
16
+ 3
17
+ 4
18
+ … +6 lines (ctrl+o to expand)
19
+
20
+ ● Update(notes.txt)
21
+ ⎿ Updated notes.txt with 1 addition and 1 removal
22
+ 1 alpha
23
+ 2 - beta
24
+ 2 + BETA
25
+ 3 gamma
26
+ ```
27
+
28
+ ## Install
29
+
30
+ ```sh
31
+ omp plugin install omp-claude-tui
32
+ # or straight from GitHub:
33
+ omp plugin install github:Jack-01041202/omp-claude-tui
34
+ ```
35
+
36
+ Restart omp afterwards; already-open sessions keep their look. Then optionally:
37
+
38
+ ```
39
+ /cc-tui fullscreen on # start every session in fullscreen scroll mode
40
+ ```
41
+
42
+ Update with `omp plugin upgrade omp-claude-tui`, remove with
43
+ `omp plugin uninstall omp-claude-tui`.
44
+
45
+ For development, link a checkout instead: `omp plugin link ~/omp-claude-tui`.
46
+
47
+ ## What it changes
48
+
49
+ | Surface | Result |
50
+ |---|---|
51
+ | `bash` | `● Bash(cmd)`, output under `⎿`, omp's exit/wall-time notices removed, `Exit code N` on failure |
52
+ | `write` | `Wrote N lines to path` plus numbered preview |
53
+ | `edit`, `apply_patch` | `Updated path with N additions and M removals` plus diff |
54
+ | `grep`, `glob`, `find` | `Search(...)` / `Find(...)` with `Found N …`; listing on Ctrl+O |
55
+ | `todo` | `Update Todos` checklist (`☐` / `☒`) |
56
+ | `read` (URL reads only) | `Read N lines` |
57
+ | Working row | `<current intent>… (12s)` |
58
+ | Composer | omp's built-in `claude` shape, only when `composer.shape` is not configured |
59
+ | Input position | pinned to the bottom row even while the transcript is shorter than the screen (blank rows padded on top) |
60
+
61
+ Bullet: dim while pending, blinking while running, green on success, red on failure.
62
+
63
+ Not changed: plain file reads (omp draws them in its own grouped read card), `task`
64
+ (live subagent cards use the tool's own renderer), MCP and other tools, the footer
65
+ (`setFooter` is a no-op in omp's interactive mode), and Tern's native view.
66
+
67
+ ## Scroll mode
68
+
69
+ omp renders the main session inline, so scrolling the terminal back moves the
70
+ input away with everything else. Press `PgUp` (or run `/cc-tui scroll`) to open
71
+ a fullscreen view: the session transcript scrolls on top, and omp's own input
72
+ editor stays docked at the bottom — the same editor instance, so slash and agent
73
+ command completion, `@` files, history, the status line and Enter behave as in
74
+ the normal view.
75
+
76
+ | Key | Action |
77
+ |---|---|
78
+ | Mouse wheel, `PgUp` / `PgDn` | scroll the transcript (scrolling to the bottom resumes following new output) |
79
+ | Drag | select text in the transcript; copies to the clipboard on release |
80
+ | Double-click / triple-click | select and copy a word / a line |
81
+ | `Ctrl+O` | expand tool output in the transcript |
82
+ | `Esc` | close an open completion menu, otherwise return to the normal view |
83
+ | any other key | goes to the input editor (and clears the selection) |
84
+
85
+ Copying uses `pbcopy` on macOS and OSC 52 elsewhere; the key hint row briefly
86
+ shows `Copied N characters`. Selection covers the transcript rows on screen
87
+ (no auto-scroll while dragging); scrolling clears it. Holding `Shift` still
88
+ gives the terminal's native selection.
89
+
90
+ When omp opens a dialog (approval, `ask`, a selector), scroll mode closes and the
91
+ dialog appears in the normal view.
92
+
93
+ ### Default fullscreen
94
+
95
+ `/cc-tui fullscreen on` makes scroll mode the default, saved across launches:
96
+ every session (startup, `/new`, resume, fork) opens in it, and after a dialog is
97
+ answered the view comes back on its own. `Esc` switches to the normal view until
98
+ the next session starts; `PgUp` returns. `/cc-tui fullscreen off` restores the
99
+ normal view as default and closes the view.
100
+
101
+ Limits: new text appears once each message is saved to the session file, not
102
+ token by token; needs a saved session (not `--no-session`). `PgUp` no longer
103
+ pages a multi-line draft in the normal editor.
104
+
105
+ ## Command
106
+
107
+ ```
108
+ /cc-tui status current state
109
+ /cc-tui scroll open scroll mode (same as PgUp)
110
+ /cc-tui fullscreen on|off make scroll mode the default for every session (default off)
111
+ /cc-tui on | off toggle (applies to new and repainted cards)
112
+ /cc-tui lines <n> rows shown under ⎿ while collapsed (default 4)
113
+ /cc-tui pin on|off pin the input to the bottom row (default on)
114
+ ```
115
+
116
+ Settings persist in `~/.omp/agent/omp-claude-tui.json`.
117
+
118
+ ## How it works / risk
119
+
120
+ omp exposes no public API for restyling built-in tool cards. The plugin wraps
121
+ entries of omp's internal renderer registry (`toolRenderers` from
122
+ `@oh-my-pi/pi-tui/tools`). This is display-only: tool definitions the model sees,
123
+ approval and execution are untouched. Each wrapped hook falls back to omp's
124
+ original renderer when it throws or the plugin is off. An omp upgrade that
125
+ changes the registry or the args/details shapes can revert cards to omp's look;
126
+ if the registry is missing, the plugin warns at session start and stays inert.
127
+
128
+ The bottom pin wraps omp's internal `Composer.prototype.renderFrame` (from
129
+ `@oh-my-pi/pi-tui/prompt/composer`) and pads short frames with blank rows on top.
130
+ If that module is missing, `/cc-tui status` reports `pin input unavailable`.
131
+ With `tui.mouse` on, clicks on live subagent rows can land a few rows off while
132
+ the frame is padded, since omp hit-tests against the unpadded frame.
133
+
134
+ Scroll mode mounts omp's internal fullscreen transcript viewer
135
+ (`@oh-my-pi/pi-tui/overlays/agent-transcript-viewer`, the one Agent Hub uses)
136
+ read-only on the main session file, parsed with
137
+ `@oh-my-pi/pi-coding-agent/modes/agent-hub-runtime`, and docks the live editor
138
+ taken from omp's composer (recorded by the `renderFrame` wrapper). While open it
139
+ wraps `tui.setFocus` to leave the view when focus moves to a dialog. If a module
140
+ is missing, `/cc-tui status` reports `scroll mode unavailable`.
141
+ Native fullscreen support is tracked upstream in
142
+ [can1357/oh-my-pi#10232](https://github.com/can1357/oh-my-pi/issues/10232).
package/index.ts ADDED
@@ -0,0 +1,338 @@
1
+ /**
2
+ * omp-claude-tui: Claude Code-style transcript for omp.
3
+ *
4
+ * Tool cards: wraps entries of omp's internal built-in renderer registry
5
+ * (`toolRenderers` from `@oh-my-pi/pi-tui/tools`). Display-only — model-facing
6
+ * tool definitions, approval and execution are untouched. Every wrapped hook
7
+ * falls back to omp's original renderer when ours throws or the plugin is off.
8
+ *
9
+ * Also: a live working-row label and the built-in `claude` composer shape
10
+ * (pinned only while the user has not configured `composer.shape`).
11
+ */
12
+ import fs from "node:fs";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
16
+ import { lookup } from "@oh-my-pi/pi-coding-agent/config/registry";
17
+ import { type ToolRenderer, toolRenderers } from "@oh-my-pi/pi-tui/tools";
18
+ import { createScrollMode } from "./src/scroll-mode";
19
+ import { type ClaudeRenderer, createRenderers, type NoticeStrippers } from "./src/tools";
20
+
21
+ const ORIGINAL = Symbol.for("omp-claude-tui.original");
22
+ const ORIGINAL_FRAME = Symbol.for("omp-claude-tui.renderFrame");
23
+ const CONFIG_PATH = path.join(os.homedir(), ".omp", "agent", "omp-claude-tui.json");
24
+ const DEFAULT_PREVIEW_LINES = 4;
25
+ /** `/cc-tui` arguments shown in the completion menu; `"<sub> on"` forms appear once the subcommand is typed. */
26
+ const CC_TUI_ARGS: readonly (readonly [value: string, description: string])[] = [
27
+ ["scroll", "Open scroll mode (fullscreen, input pinned at the bottom); same as PgUp"],
28
+ ["fullscreen", "Default fullscreen: every session opens in scroll mode"],
29
+ ["fullscreen on", "Make fullscreen the default and enter it now"],
30
+ ["fullscreen off", "Stop defaulting to fullscreen and return to the normal view"],
31
+ ["on", "Enable the plugin (Claude-style tool cards, working row)"],
32
+ ["off", "Disable the plugin and restore omp's native look"],
33
+ ["lines", "Rows shown under ⎿ while tool output is collapsed, e.g. lines 6"],
34
+ ["pin", "Push the input to the bottom when the transcript is shorter than the screen"],
35
+ ["pin on", "Pin the input to the bottom"],
36
+ ["pin off", "Stop pinning the input to the bottom"],
37
+ ["status", "Show current settings"],
38
+ ];
39
+
40
+ interface Config {
41
+ enabled: boolean;
42
+ previewLines: number;
43
+ /** Keep the input on the bottom row while the transcript is shorter than the screen. */
44
+ pinInput: boolean;
45
+ /** Default fullscreen: every session starts in scroll mode and returns to it after dialogs. */
46
+ fullscreen: boolean;
47
+ }
48
+
49
+ function loadConfig(): Config {
50
+ const config: Config = { enabled: true, previewLines: DEFAULT_PREVIEW_LINES, pinInput: true, fullscreen: false };
51
+ try {
52
+ const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")) as Partial<Config>;
53
+ if (typeof raw.enabled === "boolean") config.enabled = raw.enabled;
54
+ if (typeof raw.previewLines === "number" && raw.previewLines >= 1) config.previewLines = Math.floor(raw.previewLines);
55
+ if (typeof raw.pinInput === "boolean") config.pinInput = raw.pinInput;
56
+ if (typeof raw.fullscreen === "boolean") config.fullscreen = raw.fullscreen;
57
+ } catch {
58
+ // Missing or unreadable config: defaults.
59
+ }
60
+ return config;
61
+ }
62
+
63
+ function saveConfig(config: Config): void {
64
+ fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
65
+ fs.writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, "\t")}\n`);
66
+ }
67
+
68
+ type Wrapped = ToolRenderer & { [ORIGINAL]?: ToolRenderer };
69
+
70
+ /**
71
+ * Replace each registry entry with a delegating wrapper. Wrappers are built from
72
+ * the pristine renderer (kept under ORIGINAL), so a plugin reload re-wraps
73
+ * instead of stacking wrappers. Returns the names actually wrapped.
74
+ */
75
+ function install(renderers: Record<string, ClaudeRenderer>, config: Config): string[] {
76
+ const installed: string[] = [];
77
+ for (const [name, ours] of Object.entries(renderers)) {
78
+ const current = toolRenderers[name] as Wrapped | undefined;
79
+ const base = current?.[ORIGINAL] ?? current;
80
+ if (!base || typeof base.renderCall !== "function" || typeof base.renderResult !== "function") continue;
81
+ const wrapped: Wrapped = {
82
+ ...base,
83
+ renderCall(args, options, theme) {
84
+ if (!config.enabled) return base.renderCall(args, options, theme);
85
+ try {
86
+ return ours.renderCall(args, options, theme);
87
+ } catch {
88
+ return base.renderCall(args, options, theme);
89
+ }
90
+ },
91
+ renderResult(result, options, theme, args) {
92
+ if (!config.enabled) return base.renderResult(result, options, theme, args);
93
+ try {
94
+ return ours.renderResult(result, options, theme, args);
95
+ } catch {
96
+ return base.renderResult(result, options, theme, args);
97
+ }
98
+ },
99
+ };
100
+ // Read at render time by omp, so toggling the plugin applies to existing cards on their next repaint.
101
+ Object.defineProperties(wrapped, {
102
+ // Our result component redraws the header, so it can carry the status color.
103
+ mergeCallAndResult: { get: () => (config.enabled ? true : base.mergeCallAndResult), enumerable: true },
104
+ // No background box: Claude-style rows sit in the response flow.
105
+ inline: { get: () => (config.enabled ? true : base.inline), enumerable: true },
106
+ // The running bullet blinks on the shared spinner tick.
107
+ animatedPendingPreview: { get: () => (config.enabled ? true : base.animatedPendingPreview), enumerable: true },
108
+ animatedPartialResult: { get: () => (config.enabled ? true : base.animatedPartialResult), enumerable: true },
109
+ });
110
+ wrapped[ORIGINAL] = base;
111
+ toolRenderers[name] = wrapped;
112
+ installed.push(name);
113
+ }
114
+ return installed;
115
+ }
116
+
117
+ /**
118
+ * omp's own notice strippers, when its module resolves inside this omp build.
119
+ * Dynamic on purpose: `tools/output-meta` is an internal subpath that a future
120
+ * omp build may drop, and a failed static import would fail the whole plugin load.
121
+ */
122
+ async function loadStrippers(): Promise<NoticeStrippers> {
123
+ try {
124
+ const mod: NoticeStrippers = await import("@oh-my-pi/pi-tui/tools/output-meta");
125
+ return { stripOutputNotice: mod.stripOutputNotice, stripRawOutputArtifactNotice: mod.stripRawOutputArtifactNotice };
126
+ } catch {
127
+ return {};
128
+ }
129
+ }
130
+
131
+ /** Pins the built-in `claude` composer shape unless the user configured one. */
132
+ function composerShape(pi: ExtensionAPI): (enabled: boolean) => void {
133
+ const handle = lookup("composer.shape");
134
+ const scope = pi.pi.settings;
135
+ let pinned = false;
136
+ return enabled => {
137
+ if (!handle) return;
138
+ try {
139
+ if (enabled && !pinned && !handle.isConfigured(scope)) {
140
+ handle.override(scope, "claude");
141
+ pinned = true;
142
+ } else if (!enabled && pinned) {
143
+ handle.clearOverride(scope);
144
+ pinned = false;
145
+ }
146
+ } catch {
147
+ // Unknown setting shape in this omp build: leave the composer alone.
148
+ }
149
+ };
150
+ }
151
+
152
+ function formatElapsed(ms: number): string {
153
+ const s = Math.floor(ms / 1000);
154
+ return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
155
+ }
156
+
157
+ /** Working-row label `<intent>… (12s)`, re-applied after omp swaps in each tool intent. */
158
+ function workingRow(pi: ExtensionAPI, config: Config): void {
159
+ let start = 0;
160
+ let intent: string | undefined;
161
+ let active = false;
162
+ const label = () => `${intent ?? "Working"}… (${formatElapsed(Date.now() - start)})`;
163
+ // omp's interactive controller accepts a live label function; the declared type is string.
164
+ const apply = (ctx: ExtensionContext) => {
165
+ if (active && config.enabled && ctx.hasUI) ctx.ui.setWorkingMessage(label as unknown as string);
166
+ };
167
+ pi.on("agent_start", (_event, ctx) => {
168
+ start = Date.now();
169
+ intent = undefined;
170
+ active = true;
171
+ apply(ctx);
172
+ });
173
+ pi.on("tool_execution_start", (event, ctx) => {
174
+ const next = event.intent?.trim().replace(/\.+$/, "");
175
+ if (next) intent = next;
176
+ apply(ctx);
177
+ });
178
+ pi.on("agent_end", (_event, ctx) => {
179
+ active = false;
180
+ if (config.enabled && ctx.hasUI) ctx.ui.setWorkingMessage();
181
+ });
182
+ }
183
+
184
+ interface FrameViewport {
185
+ readonly columns: number;
186
+ readonly rows: number;
187
+ }
188
+ interface FramePlan {
189
+ readonly viewport: readonly string[];
190
+ }
191
+ type RenderFrame = (this: unknown, viewport: FrameViewport) => FramePlan;
192
+
193
+ /** The composer omp is painting with, recorded on each frame; scroll mode docks its editor and status line. */
194
+ let liveComposer: { readonly editor?: unknown; readonly ui?: { readonly children?: readonly unknown[] } } | undefined;
195
+
196
+ /**
197
+ * The composer's status-line slot (host · model · cwd · branch). omp keeps it in a
198
+ * private field but always mounts it as the TUI's last root (`Composer` re-adds it
199
+ * after every `setRuntimeChildren`); it is the only root with a `setComponent` slot.
200
+ */
201
+ function liveStatusLine(): unknown {
202
+ const children = liveComposer?.ui?.children ?? [];
203
+ const last = children.at(-1);
204
+ return last && typeof last === "object" && "setComponent" in last && "render" in last ? last : undefined;
205
+ }
206
+
207
+ /**
208
+ * Pin the input to the bottom row. omp paints its live area (transcript tail,
209
+ * editor, status) directly below whatever the terminal held at startup, so a
210
+ * short session leaves the input mid-screen; once content fills the screen omp
211
+ * already keeps it on the bottom row. Padding a short frame with blank rows on
212
+ * top pushes it down to the bottom row in that remaining case.
213
+ *
214
+ * Dynamic on purpose: `prompt/composer` is an internal subpath; a failed static
215
+ * import would fail the whole plugin load. Returns false when unavailable.
216
+ */
217
+ async function pinInput(config: Config): Promise<boolean> {
218
+ let proto: Record<PropertyKey, unknown>;
219
+ try {
220
+ const mod: { Composer?: { prototype: Record<PropertyKey, unknown> } } = await import(
221
+ "@oh-my-pi/pi-tui/prompt/composer"
222
+ );
223
+ if (!mod.Composer) return false;
224
+ proto = mod.Composer.prototype;
225
+ } catch {
226
+ return false;
227
+ }
228
+ // Wrap the pristine method so a plugin reload does not stack wrappers.
229
+ const original = (proto[ORIGINAL_FRAME] ?? proto.renderFrame) as RenderFrame | undefined;
230
+ if (typeof original !== "function") return false;
231
+ proto[ORIGINAL_FRAME] = original;
232
+ proto.renderFrame = function (this: NonNullable<typeof liveComposer>, viewport: FrameViewport): FramePlan {
233
+ liveComposer = this;
234
+ const plan = original.call(this, viewport);
235
+ if (!config.enabled || !config.pinInput) return plan;
236
+ const rows = viewport.rows;
237
+ const lines = plan.viewport;
238
+ if (lines.length === 0 || lines.length >= rows) return plan;
239
+ return { ...plan, viewport: [...Array<string>(rows - lines.length).fill(""), ...lines] };
240
+ };
241
+ return true;
242
+ }
243
+
244
+ /**
245
+ * One settings object for the whole process. omp re-runs the factory for every
246
+ * subagent session, while the hooks it installs (renderer registry, composer
247
+ * prototype) are process-global; each run re-installs them over its own `config`,
248
+ * so sharing it keeps `/cc-tui` in the main session in charge of what is drawn.
249
+ */
250
+ let sharedConfig: Config | undefined;
251
+
252
+ export default async function ompClaudeTui(pi: ExtensionAPI): Promise<void> {
253
+ const config = (sharedConfig ??= loadConfig());
254
+ const strippers = await loadStrippers();
255
+ const renderers = createRenderers({ previewLines: () => config.previewLines }, strippers);
256
+ const installed = install(renderers, config);
257
+ const applyComposer = composerShape(pi);
258
+ applyComposer(config.enabled);
259
+ workingRow(pi, config);
260
+ const pinned = await pinInput(config);
261
+ const scrollMode = await createScrollMode(pi, {
262
+ mainEditor: () => liveComposer?.editor,
263
+ mainStatusLine: liveStatusLine,
264
+ isDefault: () => config.enabled && config.fullscreen,
265
+ });
266
+
267
+ // Opening resolves when the view closes; report a failure instead of leaving it unhandled.
268
+ const openScrollMode = (ctx: ExtensionContext) => {
269
+ scrollMode.open(ctx).catch((error: unknown) => {
270
+ ctx.ui.notify(`omp-claude-tui: scroll mode failed: ${error instanceof Error ? error.message : String(error)}`, "error");
271
+ });
272
+ };
273
+
274
+ // PgUp opens scroll mode, as it scrolls Claude Code's fullscreen transcript.
275
+ // It takes over the editor's PgUp, which otherwise only pages a multi-line draft.
276
+ pi.registerShortcut("pageUp", {
277
+ description: "Scroll the transcript (fullscreen view, input stays at the bottom)",
278
+ handler: openScrollMode,
279
+ });
280
+
281
+ pi.on("session_start", (_event, ctx) => {
282
+ if (installed.length === 0) {
283
+ ctx.ui.notify("omp-claude-tui: omp's renderer registry has an unknown shape; using native rendering", "warning");
284
+ }
285
+ });
286
+
287
+ pi.registerCommand("cc-tui", {
288
+ description: "Claude-style transcript: scroll | fullscreen on|off | on | off | lines <n> | pin on|off | status",
289
+ getArgumentCompletions: prefix =>
290
+ CC_TUI_ARGS.filter(([value]) => value.startsWith(prefix) && (prefix.includes(" ") || !value.includes(" "))).map(
291
+ ([value, description]) => ({ value, label: value, description }),
292
+ ),
293
+ handler: async (args, ctx) => {
294
+ const [cmd = "status", value] = args.trim().split(/\s+/);
295
+ if (cmd === "scroll") {
296
+ openScrollMode(ctx);
297
+ return;
298
+ }
299
+ if (cmd === "on" || cmd === "off") {
300
+ config.enabled = cmd === "on";
301
+ applyComposer(config.enabled);
302
+ if (!config.enabled && ctx.hasUI) ctx.ui.setWorkingMessage();
303
+ } else if (cmd === "lines") {
304
+ const n = Number(value);
305
+ if (!Number.isInteger(n) || n < 1) {
306
+ ctx.ui.notify("Usage: /cc-tui lines <positive integer>", "error");
307
+ return;
308
+ }
309
+ config.previewLines = n;
310
+ } else if (cmd === "pin") {
311
+ if (value !== "on" && value !== "off") {
312
+ ctx.ui.notify("Usage: /cc-tui pin on|off", "error");
313
+ return;
314
+ }
315
+ config.pinInput = value === "on";
316
+ } else if (cmd === "fullscreen") {
317
+ if (value !== "on" && value !== "off") {
318
+ ctx.ui.notify("Usage: /cc-tui fullscreen on|off", "error");
319
+ return;
320
+ }
321
+ config.fullscreen = value === "on";
322
+ saveConfig(config);
323
+ if (config.fullscreen) openScrollMode(ctx);
324
+ else await scrollMode.close();
325
+ return;
326
+ } else if (cmd !== "status") {
327
+ ctx.ui.notify("Usage: /cc-tui scroll | fullscreen on|off | on | off | lines <n> | pin on|off | status", "error");
328
+ return;
329
+ }
330
+ if (cmd !== "status") saveConfig(config);
331
+ const pin = pinned ? (config.pinInput ? "on" : "off") : "unavailable";
332
+ ctx.ui.notify(
333
+ `omp-claude-tui: ${config.enabled ? "on" : "off"} · preview ${config.previewLines} lines · pin input ${pin} · scroll mode ${scrollMode.available ? `PgUp, default ${config.fullscreen ? "on" : "off"}` : "unavailable"} · tools: ${installed.join(", ") || "none"}${cmd === "status" ? "" : " (new tool cards; scroll-back keeps its look)"}`,
334
+ "info",
335
+ );
336
+ },
337
+ });
338
+ }
package/package.json CHANGED
@@ -1,6 +1,19 @@
1
1
  {
2
- "name": "omp-claude-tui",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "omp-claude-tui",
3
+ "version": "0.1.0",
4
+ "description": "Claude Code-style transcript, fullscreen scroll mode and in-app copy for omp (oh-my-pi)",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Jack-01041202",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/Jack-01041202/omp-claude-tui.git"
11
+ },
12
+ "homepage": "https://github.com/Jack-01041202/omp-claude-tui#readme",
13
+ "bugs": "https://github.com/Jack-01041202/omp-claude-tui/issues",
14
+ "keywords": ["omp-plugin", "oh-my-pi", "omp", "tui", "claude-code"],
15
+ "files": ["index.ts", "src", "README.md", "LICENSE"],
16
+ "omp": {
17
+ "extensions": ["./index.ts"]
18
+ }
19
+ }
package/src/render.ts ADDED
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Shared Claude Code-style rendering primitives: `● Name(arg)` headers and the
3
+ * `⎿` result gutter.
4
+ */
5
+ import os from "node:os";
6
+ import { type Component, replaceTabs, truncateToWidth } from "@oh-my-pi/pi-tui";
7
+ import type { ToolRenderer } from "@oh-my-pi/pi-tui/tools";
8
+
9
+ export type Theme = Parameters<ToolRenderer["renderCall"]>[2];
10
+ export type RenderOptions = Parameters<ToolRenderer["renderResult"]>[1];
11
+ export type RenderResult = Parameters<ToolRenderer["renderResult"]>[0];
12
+
13
+ const FIRST_GUTTER = " ⎿ ";
14
+ const NEXT_GUTTER = " ";
15
+
16
+ /** Static styled lines truncated to the render width; same array while the width is unchanged. */
17
+ export class Lines implements Component {
18
+ #lines: readonly string[];
19
+ #width = -1;
20
+ #cache: readonly string[] = [];
21
+
22
+ constructor(lines: readonly string[]) {
23
+ this.#lines = lines;
24
+ }
25
+
26
+ render(width: number): readonly string[] {
27
+ if (width !== this.#width) {
28
+ this.#width = width;
29
+ this.#cache = this.#lines.map(line => truncateToWidth(line, width));
30
+ }
31
+ return this.#cache;
32
+ }
33
+
34
+ invalidate(): void {
35
+ this.#width = -1;
36
+ }
37
+ }
38
+
39
+ export type CallState = "pending" | "running" | "success" | "error";
40
+
41
+ /** Status bullet: blinking while running, dim while pending, green/red once settled. */
42
+ export function bullet(theme: Theme, state: CallState, spinnerFrame?: number): string {
43
+ switch (state) {
44
+ case "success":
45
+ return theme.fg("success", "●");
46
+ case "error":
47
+ return theme.fg("error", "●");
48
+ case "running":
49
+ return spinnerFrame !== undefined && spinnerFrame % 2 === 1 ? " " : theme.fg("dim", "●");
50
+ default:
51
+ return theme.fg("dim", "●");
52
+ }
53
+ }
54
+
55
+ /** State of a settled-or-streaming result. */
56
+ export function resultState(result: RenderResult, options: RenderOptions): CallState {
57
+ if (options.isPartial) return "running";
58
+ return result.isError ? "error" : "success";
59
+ }
60
+
61
+ /**
62
+ * State of a call that has no result yet. omp ticks the spinner while the call's
63
+ * arguments are still streaming, so only `executionStarted` means it is running.
64
+ */
65
+ export function callState(options: RenderOptions): CallState {
66
+ return options.executionStarted ? "running" : "pending";
67
+ }
68
+
69
+ /** `● Name(arg)`; the parenthesised part is omitted when `arg` is undefined. */
70
+ export function header(theme: Theme, state: CallState, name: string, arg?: string, spinnerFrame?: number): string {
71
+ const head = `${bullet(theme, state, spinnerFrame)} ${theme.bold(name)}`;
72
+ if (arg === undefined) return head;
73
+ return `${head}${theme.fg("muted", "(")}${replaceTabs(arg)}${theme.fg("muted", ")")}`;
74
+ }
75
+
76
+ export interface GutterOptions {
77
+ expanded: boolean;
78
+ /** Body rows shown while collapsed. */
79
+ limit: number;
80
+ /** Collapse to the last rows (streaming output) instead of the first. */
81
+ tail?: boolean;
82
+ /** Always shown before the body (a summary sentence); not counted against `limit`. */
83
+ lead?: readonly string[];
84
+ /** Always shown after the body (exit code, timeout); not counted against `limit`. */
85
+ trailer?: readonly string[];
86
+ }
87
+
88
+ /**
89
+ * Rows under the header behind the `⎿` gutter. Collapsed, only `limit` body rows
90
+ * show (the tail while streaming), with an expand hint for the rest; `lead` and
91
+ * `trailer` rows are never folded away.
92
+ */
93
+ export function gutter(theme: Theme, rows: readonly string[], opts: GutterOptions): string[] {
94
+ let shown: string[] = rows.map(row => replaceTabs(row));
95
+ let hidden = 0;
96
+ if (!opts.expanded && shown.length > opts.limit) {
97
+ hidden = shown.length - opts.limit;
98
+ shown = opts.tail ? shown.slice(-opts.limit) : shown.slice(0, opts.limit);
99
+ }
100
+ if (hidden > 0) {
101
+ const hint = theme.fg("dim", `… +${hidden} line${hidden === 1 ? "" : "s"} (ctrl+o to expand)`);
102
+ if (opts.tail) shown.unshift(hint);
103
+ else shown.push(hint);
104
+ }
105
+ const all = [...(opts.lead ?? []).map(replaceTabs), ...shown, ...(opts.trailer ?? []).map(replaceTabs)];
106
+ return all.map((row, i) => (i === 0 ? FIRST_GUTTER : NEXT_GUTTER) + row);
107
+ }
108
+
109
+ /** Joined text content of a tool result, trailing whitespace removed. */
110
+ export function resultText(result: RenderResult): string {
111
+ return result.content
112
+ .filter(block => block.type === "text" && typeof block.text === "string")
113
+ .map(block => block.text)
114
+ .join("\n")
115
+ .replace(/\s+$/, "");
116
+ }
117
+
118
+ /** Non-empty text as lines; empty text as no lines. */
119
+ export function splitLines(text: string | undefined): string[] {
120
+ if (!text) return [];
121
+ return text.replace(/\s+$/, "").split("\n");
122
+ }
123
+
124
+ /** First line of a possibly multi-line value, marked with `…` when more follows. */
125
+ export function firstLine(text: unknown): string {
126
+ if (typeof text !== "string") return "";
127
+ const lines = text.trim().split("\n");
128
+ return lines.length > 1 ? `${lines[0]} …` : (lines[0] ?? "");
129
+ }
130
+
131
+ /** macOS: `/tmp`, `/var`, `/etc` are symlinks into `/private`; paths may come either way. */
132
+ function unprivate(p: string): string {
133
+ return p.replace(/^\/private(?=\/(?:tmp|var|etc)\/)/, "");
134
+ }
135
+
136
+ /** Display path: relative to the working directory, `~` for home, otherwise as given. */
137
+ export function displayPath(value: unknown): string {
138
+ if (Array.isArray(value)) return value.map(displayPath).join(", ");
139
+ if (typeof value !== "string") return "";
140
+ const target = unprivate(value);
141
+ for (const dir of new Set([process.cwd(), process.env.PWD].filter(Boolean).map(d => unprivate(d!)))) {
142
+ if (target.startsWith(`${dir}/`)) return target.slice(dir.length + 1);
143
+ }
144
+ const home = os.homedir();
145
+ if (target.startsWith(`${home}/`)) return `~${target.slice(home.length)}`;
146
+ return value;
147
+ }
148
+
149
+ export function plural(count: number, noun: string, pluralNoun = `${noun}s`): string {
150
+ return `${count} ${count === 1 ? noun : pluralNoun}`;
151
+ }