@wrongstack/tui 0.1.3 → 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/LICENSE CHANGED
@@ -1,17 +1,21 @@
1
- Apache License
2
- Version 2.0, January 2004
3
- http://www.apache.org/licenses/
1
+ MIT License
4
2
 
5
- Copyright 2026 ECOSTACK TECHNOLOGY OÜ
3
+ Copyright (c) 2026 ECOSTACK TECHNOLOGY OÜ
6
4
 
7
- Licensed under the Apache License, Version 2.0 (the "License");
8
- you may not use this file except in compliance with the License.
9
- You may obtain a copy of the License at
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:
10
11
 
11
- http://www.apache.org/licenses/LICENSE-2.0
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
12
14
 
13
- Unless required by applicable law or agreed to in writing, software
14
- distributed under the License is distributed on an "AS IS" BASIS,
15
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
- See the License for the specific language governing permissions and
17
- limitations under the License.
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 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 path2 from 'path';
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 sep = lines[start + 1] ?? "";
36
- if (!SEP_RE.test(sep) || !/-/.test(sep)) return start;
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(sep) {
63
- const t = sep.trim();
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 && toolStream.text ? tailForDisplay(toolStream.text, MAX_STREAM_DISPLAY_CHARS) : "";
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
- git ? /* @__PURE__ */ jsxs(Fragment, { children: [
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 ? path2.join(root, rel) : root;
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 = path2.join(os.tmpdir(), `wstack-clip-${Date.now()}.png`);
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 = path2.join(os.tmpdir(), `wstack-clip-${Date.now()}.png`);
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 = path2.join(os.tmpdir(), `wstack-clip-${Date.now()}.png`);
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
- () => lastInputTokens > 0 && maxContext > 0 ? { used: lastInputTokens, max: maxContext } : void 0,
1973
- [lastInputTokens, maxContext]
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 = path2.isAbsolute(picked) ? picked : path2.join(projectRoot, picked);
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
  );