@wrongstack/tui 0.1.4 → 0.1.7
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/README.md +99 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +54 -20
- package/dist/index.js.map +1 -1
- package/package.json +7 -4
package/README.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# @wrongstack/tui
|
|
2
|
+
|
|
3
|
+
Ink-based terminal UI for the WrongStack agent. Renders the interactive chat panel, status bar, slash-command picker, model picker, todo list, and permission-confirm dialogs.
|
|
4
|
+
|
|
5
|
+
The TUI is **lazy-loaded** by [`@wrongstack/cli`](../cli) — it only imports React/Ink when the user passes `--tui`. Plain-REPL users pay no startup cost.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm add @wrongstack/tui @wrongstack/core
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
You'd only depend on this directly if you're embedding WrongStack inside another tool and want the TUI surface. Otherwise install [`wrongstack`](../../README.md).
|
|
14
|
+
|
|
15
|
+
## Quick example
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { runTui } from '@wrongstack/tui';
|
|
19
|
+
import { Agent, DefaultEventBus, SlashCommandRegistry, DefaultAttachmentStore } from '@wrongstack/core';
|
|
20
|
+
|
|
21
|
+
const exitCode = await runTui({
|
|
22
|
+
agent, // configured Agent instance
|
|
23
|
+
slashRegistry, // SlashCommandRegistry
|
|
24
|
+
attachments: new DefaultAttachmentStore(),
|
|
25
|
+
events: new DefaultEventBus(),
|
|
26
|
+
model: 'claude-sonnet-4-6',
|
|
27
|
+
banner: true,
|
|
28
|
+
yolo: false,
|
|
29
|
+
appVersion: '0.1.6',
|
|
30
|
+
provider: 'anthropic',
|
|
31
|
+
family: 'anthropic',
|
|
32
|
+
keyTail: '…ABC',
|
|
33
|
+
effectiveMaxContext: 200_000,
|
|
34
|
+
altScreen: false,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
process.exit(exitCode);
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## What you get
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
┌─ banner ─────────────────────────────────────────────┐
|
|
44
|
+
│ wrongstack 0.1.6 — anthropic · claude-sonnet-4-6 │
|
|
45
|
+
└──────────────────────────────────────────────────────┘
|
|
46
|
+
user> refactor auth.ts to async/await
|
|
47
|
+
⠋ thinking (3 tools used · 4.2k tokens · 1.3s)
|
|
48
|
+
…
|
|
49
|
+
> █ ⚠ YOLO
|
|
50
|
+
───────────────────────────────────────────── ctx 47%
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
- **History pane** — assistant text, tool calls, tool results, errors, turn summaries
|
|
54
|
+
- **Streaming text** — partial deltas render live; on abort, partial response is preserved
|
|
55
|
+
- **Status bar** — model · provider · context-window % · YOLO chip · spinner
|
|
56
|
+
- **Input box** — multi-line buffer with bracketed-paste detection, history (↑/↓), placeholder pills for attachments
|
|
57
|
+
- **Pickers** — fuzzy file picker (`@`), slash picker (`/`), model picker (Ctrl+M)
|
|
58
|
+
- **Permission dialog** — modal y/n/always/deny for `confirm`-permission tools
|
|
59
|
+
- **Todo list** — sidebar reflecting `ctx.todos`
|
|
60
|
+
- **Attachments** — images and files dropped into the input become inline content blocks
|
|
61
|
+
|
|
62
|
+
## Key bindings
|
|
63
|
+
|
|
64
|
+
| Key | Effect |
|
|
65
|
+
|-----|--------|
|
|
66
|
+
| `Enter` | Submit |
|
|
67
|
+
| `Shift+Enter` (or `\` newline) | Insert newline |
|
|
68
|
+
| `Ctrl+C` (once) | Abort current turn |
|
|
69
|
+
| `Ctrl+C` (twice) | Exit |
|
|
70
|
+
| `Ctrl+D` (empty buffer) | Exit |
|
|
71
|
+
| `↑` / `↓` | History navigation when buffer empty |
|
|
72
|
+
| `@` | File picker |
|
|
73
|
+
| `/` (at start) | Slash command picker |
|
|
74
|
+
| `Esc` | Close any picker / dialog |
|
|
75
|
+
| `Ctrl+L` | Clear screen (TUI keeps state — equivalent to scrolling) |
|
|
76
|
+
|
|
77
|
+
## Options worth knowing
|
|
78
|
+
|
|
79
|
+
- **`altScreen: false`** (default) — render into normal scrollback so the user keeps native terminal scroll + history after exit. Set `true` for full-screen mode (no scrollback).
|
|
80
|
+
- **`effectiveMaxContext`** — the context-bar denominator. Pass the model-specific value resolved via `ModelsRegistry`, not the family baseline; the 1M Opus variant has a much larger window than the 200k default.
|
|
81
|
+
- **`queueStore`** — if set, queued input survives a crash. Without it, queued lines are in-memory only.
|
|
82
|
+
- **`onClearHistory`** — invoked from the `/clear` slash command so the TUI can wipe its rendered history entries (keeping just the banner) while `Agent`/memory reset happens elsewhere.
|
|
83
|
+
- **`onAfterExit`** — called once the alt-screen has been restored on clean shutdown. Use it to print "session saved" hints into the user's normal terminal (alt-screen exit erases the TUI view).
|
|
84
|
+
|
|
85
|
+
## Architecture
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
runTui — entry; sets up bracketed paste, alt-screen, signal handlers
|
|
89
|
+
↓
|
|
90
|
+
App (React component) — useReducer-driven state machine
|
|
91
|
+
↓ dispatches events to ↓
|
|
92
|
+
EventBus — agent.run() emits these, the TUI subscribes
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
State is a single `useReducer` `State` shape with discriminated-union `Action`s. The reducer is exported (`reducer`) and unit-tested.
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
MIT
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import React from 'react';
|
|
1
2
|
import { Agent, SlashCommandRegistry, AttachmentStore, EventBus, TokenCounter, QueueStore } from '@wrongstack/core';
|
|
2
3
|
|
|
3
4
|
interface ProviderOption {
|
|
@@ -58,6 +59,12 @@ interface RunTuiOptions {
|
|
|
58
59
|
* TUI view.
|
|
59
60
|
*/
|
|
60
61
|
onAfterExit?: () => void;
|
|
62
|
+
/** Called from /clear so the TUI can wipe its history entries while agent.ctx + memory are cleared separately. */
|
|
63
|
+
onClearHistory?: (dispatch: React.Dispatch<{
|
|
64
|
+
type: 'clearHistory';
|
|
65
|
+
} | {
|
|
66
|
+
type: 'resetContextChip';
|
|
67
|
+
}>) => void;
|
|
61
68
|
}
|
|
62
69
|
declare function runTui(opts: RunTuiOptions): Promise<number>;
|
|
63
70
|
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React, { useState, useReducer, useRef, useEffect, useMemo } from 'react';
|
|
2
2
|
import { render, useApp, Box, useStdout, Static, Text, useInput } from 'ink';
|
|
3
|
-
import * as
|
|
3
|
+
import * as path3 from 'path';
|
|
4
4
|
import * as fs2 from 'fs/promises';
|
|
5
5
|
import { InputBuilder } from '@wrongstack/core';
|
|
6
6
|
import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
|
|
@@ -32,8 +32,8 @@ var SEP_RE = /^\s*\|[\s\-:|]+\|\s*$/;
|
|
|
32
32
|
function detectTable(lines, start) {
|
|
33
33
|
if (start + 1 >= lines.length) return start;
|
|
34
34
|
if (!ROW_RE.test(lines[start] ?? "")) return start;
|
|
35
|
-
const
|
|
36
|
-
if (!SEP_RE.test(
|
|
35
|
+
const sep2 = lines[start + 1] ?? "";
|
|
36
|
+
if (!SEP_RE.test(sep2) || !/-/.test(sep2)) return start;
|
|
37
37
|
let end = start + 2;
|
|
38
38
|
while (end < lines.length && ROW_RE.test(lines[end] ?? "")) end++;
|
|
39
39
|
return end;
|
|
@@ -59,8 +59,8 @@ function parseCells(line) {
|
|
|
59
59
|
parts.push(buf);
|
|
60
60
|
return parts.map((c) => c.trim());
|
|
61
61
|
}
|
|
62
|
-
function parseAlign(
|
|
63
|
-
const t =
|
|
62
|
+
function parseAlign(sep2) {
|
|
63
|
+
const t = sep2.trim();
|
|
64
64
|
const left = t.startsWith(":");
|
|
65
65
|
const right = t.endsWith(":");
|
|
66
66
|
if (left && right) return "center";
|
|
@@ -191,7 +191,7 @@ function History({ entries, streamingText, toolStream }) {
|
|
|
191
191
|
const { stdout } = useStdout();
|
|
192
192
|
const termWidth = stdout?.columns ?? 80;
|
|
193
193
|
const tail = streamingText ? tailForDisplay(streamingText, MAX_STREAM_DISPLAY_CHARS) : "";
|
|
194
|
-
const toolTail = toolStream
|
|
194
|
+
const toolTail = toolStream?.text ? tailForDisplay(toolStream.text, MAX_STREAM_DISPLAY_CHARS) : "";
|
|
195
195
|
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
196
196
|
/* @__PURE__ */ jsx(Static, { items: entries, children: (entry) => /* @__PURE__ */ jsx(Box, { marginBottom: entry.kind === "turn-summary" ? 1 : 0, children: /* @__PURE__ */ jsx(Entry, { entry, termWidth }) }, entry.id) }),
|
|
197
197
|
tail ? /* @__PURE__ */ jsxs(Box, { children: [
|
|
@@ -994,14 +994,15 @@ function StatusBar({
|
|
|
994
994
|
todos,
|
|
995
995
|
git,
|
|
996
996
|
subagentCount = 0,
|
|
997
|
-
context
|
|
997
|
+
context,
|
|
998
|
+
projectName
|
|
998
999
|
}) {
|
|
999
1000
|
const usage = tokenCounter?.total();
|
|
1000
1001
|
const cost = tokenCounter?.estimateCost();
|
|
1001
1002
|
const cache2 = tokenCounter?.cacheStats();
|
|
1002
1003
|
const stateColor = state === "idle" ? "cyan" : state === "aborting" ? "yellow" : "green";
|
|
1003
1004
|
const stateLabel = state === "idle" ? "idle" : state === "aborting" ? "aborting\u2026" : "thinking\u2026";
|
|
1004
|
-
const hasSecondLine = yolo || elapsedMs !== void 0 || todos && (todos.pending > 0 || todos.inProgress > 0 || todos.completed > 0) || git !== null && git !== void 0 || subagentCount > 0;
|
|
1005
|
+
const hasSecondLine = yolo || elapsedMs !== void 0 || todos && (todos.pending > 0 || todos.inProgress > 0 || todos.completed > 0) || git !== null && git !== void 0 || subagentCount > 0 || projectName !== void 0 && projectName.length > 0;
|
|
1005
1006
|
return /* @__PURE__ */ jsxs(
|
|
1006
1007
|
Box,
|
|
1007
1008
|
{
|
|
@@ -1070,8 +1071,15 @@ function StatusBar({
|
|
|
1070
1071
|
fmtElapsed(elapsedMs)
|
|
1071
1072
|
] })
|
|
1072
1073
|
] }) : null,
|
|
1073
|
-
|
|
1074
|
+
projectName ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1074
1075
|
yolo || elapsedMs !== void 0 ? /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2502" }) : null,
|
|
1076
|
+
/* @__PURE__ */ jsxs(Text, { color: "blue", children: [
|
|
1077
|
+
"\u{1F4C1} ",
|
|
1078
|
+
projectName
|
|
1079
|
+
] })
|
|
1080
|
+
] }) : null,
|
|
1081
|
+
git ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1082
|
+
yolo || elapsedMs !== void 0 || projectName ? /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2502" }) : null,
|
|
1075
1083
|
/* @__PURE__ */ jsxs(Text, { children: [
|
|
1076
1084
|
/* @__PURE__ */ jsxs(Text, { color: "magenta", children: [
|
|
1077
1085
|
"\u2387 ",
|
|
@@ -1358,7 +1366,7 @@ async function loadIndex(root) {
|
|
|
1358
1366
|
async function walk(root, rel, depth, out) {
|
|
1359
1367
|
if (out.length >= MAX_FILES_INDEXED) return;
|
|
1360
1368
|
if (depth > MAX_DEPTH) return;
|
|
1361
|
-
const dir = rel ?
|
|
1369
|
+
const dir = rel ? path3.join(root, rel) : root;
|
|
1362
1370
|
let entries;
|
|
1363
1371
|
try {
|
|
1364
1372
|
entries = await fs2.readdir(dir, { withFileTypes: true });
|
|
@@ -1415,7 +1423,7 @@ async function readClipboardImage() {
|
|
|
1415
1423
|
return null;
|
|
1416
1424
|
}
|
|
1417
1425
|
async function readWindows() {
|
|
1418
|
-
const tmp =
|
|
1426
|
+
const tmp = path3.join(os.tmpdir(), `wstack-clip-${Date.now()}.png`);
|
|
1419
1427
|
const ps = [
|
|
1420
1428
|
"Add-Type -AssemblyName System.Windows.Forms",
|
|
1421
1429
|
"Add-Type -AssemblyName System.Drawing",
|
|
@@ -1430,7 +1438,7 @@ async function readWindows() {
|
|
|
1430
1438
|
return readPngFile(tmp);
|
|
1431
1439
|
}
|
|
1432
1440
|
async function readDarwin() {
|
|
1433
|
-
const tmp =
|
|
1441
|
+
const tmp = path3.join(os.tmpdir(), `wstack-clip-${Date.now()}.png`);
|
|
1434
1442
|
const script = [
|
|
1435
1443
|
"try",
|
|
1436
1444
|
` set the_file to (open for access POSIX file "${tmp}" with write permission)`,
|
|
@@ -1449,7 +1457,7 @@ async function readDarwin() {
|
|
|
1449
1457
|
return readPngFile(tmp);
|
|
1450
1458
|
}
|
|
1451
1459
|
async function readLinux() {
|
|
1452
|
-
const tmp =
|
|
1460
|
+
const tmp = path3.join(os.tmpdir(), `wstack-clip-${Date.now()}.png`);
|
|
1453
1461
|
const tries = [
|
|
1454
1462
|
["wl-paste", ["--type", "image/png"]],
|
|
1455
1463
|
["xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]]
|
|
@@ -1662,6 +1670,15 @@ function reducer(state, action) {
|
|
|
1662
1670
|
picker: { open: false, query: "", matches: [], selected: 0 },
|
|
1663
1671
|
slashPicker: { open: false, query: "", matches: [], selected: 0 }
|
|
1664
1672
|
};
|
|
1673
|
+
case "clearHistory": {
|
|
1674
|
+
const last = state.entries[state.entries.length - 1];
|
|
1675
|
+
return {
|
|
1676
|
+
...state,
|
|
1677
|
+
entries: last ? [last] : state.entries,
|
|
1678
|
+
queue: [],
|
|
1679
|
+
nextQueueId: 1
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1665
1682
|
case "streamDelta":
|
|
1666
1683
|
return { ...state, streamingText: state.streamingText + action.delta };
|
|
1667
1684
|
case "streamReset":
|
|
@@ -1862,6 +1879,8 @@ function reducer(state, action) {
|
|
|
1862
1879
|
return { ...state, confirm: action.info };
|
|
1863
1880
|
case "confirmClose":
|
|
1864
1881
|
return { ...state, confirm: null };
|
|
1882
|
+
case "resetContextChip":
|
|
1883
|
+
return { ...state, contextChipVersion: state.contextChipVersion + 1 };
|
|
1865
1884
|
}
|
|
1866
1885
|
}
|
|
1867
1886
|
var PASTE_THRESHOLD_CHARS = 200;
|
|
@@ -1882,7 +1901,8 @@ function App({
|
|
|
1882
1901
|
getPickableProviders,
|
|
1883
1902
|
switchProviderAndModel,
|
|
1884
1903
|
effectiveMaxContext,
|
|
1885
|
-
onExit
|
|
1904
|
+
onExit,
|
|
1905
|
+
onClearHistory
|
|
1886
1906
|
}) {
|
|
1887
1907
|
const { exit } = useApp();
|
|
1888
1908
|
const [liveModel, setLiveModel] = useState(model);
|
|
@@ -1923,7 +1943,8 @@ function App({
|
|
|
1923
1943
|
modelOptions: [],
|
|
1924
1944
|
selected: 0
|
|
1925
1945
|
},
|
|
1926
|
-
confirm: null
|
|
1946
|
+
confirm: null,
|
|
1947
|
+
contextChipVersion: 0
|
|
1927
1948
|
});
|
|
1928
1949
|
const builderRef = useRef(null);
|
|
1929
1950
|
if (builderRef.current === null) {
|
|
@@ -1931,6 +1952,10 @@ function App({
|
|
|
1931
1952
|
}
|
|
1932
1953
|
const activeCtrlRef = useRef(null);
|
|
1933
1954
|
const projectRoot = agent.ctx.projectRoot;
|
|
1955
|
+
const projectName = React.useMemo(() => {
|
|
1956
|
+
const base = path3.basename(projectRoot);
|
|
1957
|
+
return base && base !== path3.sep ? base : void 0;
|
|
1958
|
+
}, [projectRoot]);
|
|
1934
1959
|
const streamingTextRef = useRef("");
|
|
1935
1960
|
const pendingDeltaRef = useRef("");
|
|
1936
1961
|
const flushTimerRef = useRef(null);
|
|
@@ -1969,8 +1994,11 @@ function App({
|
|
|
1969
1994
|
}, [events]);
|
|
1970
1995
|
const maxContext = effectiveMaxContext ?? agent.ctx.provider.capabilities.maxContext;
|
|
1971
1996
|
const contextWindow = useMemo(
|
|
1972
|
-
() =>
|
|
1973
|
-
|
|
1997
|
+
() => {
|
|
1998
|
+
void state.contextChipVersion;
|
|
1999
|
+
return lastInputTokens > 0 && maxContext > 0 ? { used: lastInputTokens, max: maxContext } : void 0;
|
|
2000
|
+
},
|
|
2001
|
+
[lastInputTokens, maxContext, state.contextChipVersion]
|
|
1974
2002
|
);
|
|
1975
2003
|
const todos = useMemo(() => {
|
|
1976
2004
|
const counts = { pending: 0, inProgress: 0, completed: 0 };
|
|
@@ -2065,7 +2093,7 @@ function App({
|
|
|
2065
2093
|
dispatch({ type: "pickerClose" });
|
|
2066
2094
|
return;
|
|
2067
2095
|
}
|
|
2068
|
-
const absPath =
|
|
2096
|
+
const absPath = path3.isAbsolute(picked) ? picked : path3.join(projectRoot, picked);
|
|
2069
2097
|
try {
|
|
2070
2098
|
const data = await fs2.readFile(absPath, "utf8");
|
|
2071
2099
|
const placeholder = await builder.appendFile({
|
|
@@ -2567,6 +2595,10 @@ function App({
|
|
|
2567
2595
|
exit();
|
|
2568
2596
|
onExit(0);
|
|
2569
2597
|
}
|
|
2598
|
+
const cmd = trimmed.slice(1).split(/\s+/, 1)[0];
|
|
2599
|
+
if (cmd === "clear") {
|
|
2600
|
+
onClearHistory?.(dispatch);
|
|
2601
|
+
}
|
|
2570
2602
|
} catch (err) {
|
|
2571
2603
|
dispatch({
|
|
2572
2604
|
type: "addEntry",
|
|
@@ -2661,7 +2693,8 @@ function App({
|
|
|
2661
2693
|
elapsedMs,
|
|
2662
2694
|
todos,
|
|
2663
2695
|
git: gitInfo,
|
|
2664
|
-
context: contextWindow
|
|
2696
|
+
context: contextWindow,
|
|
2697
|
+
projectName
|
|
2665
2698
|
}
|
|
2666
2699
|
)
|
|
2667
2700
|
] });
|
|
@@ -2776,7 +2809,8 @@ async function runTui(opts) {
|
|
|
2776
2809
|
getPickableProviders: opts.getPickableProviders,
|
|
2777
2810
|
switchProviderAndModel: opts.switchProviderAndModel,
|
|
2778
2811
|
effectiveMaxContext: opts.effectiveMaxContext,
|
|
2779
|
-
onExit
|
|
2812
|
+
onExit,
|
|
2813
|
+
onClearHistory: opts.onClearHistory ? (dispatch) => opts.onClearHistory(dispatch) : void 0
|
|
2780
2814
|
}),
|
|
2781
2815
|
{ exitOnCtrlC: false }
|
|
2782
2816
|
);
|