@wrongstack/tui 0.3.1 → 0.3.3

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/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Agent, SlashCommandRegistry, AttachmentStore, EventBus, TokenCounter, QueueStore, Director } from '@wrongstack/core';
2
+ import { VisionAdapters } from '@wrongstack/runtime/vision';
2
3
  import React from 'react';
3
4
 
4
5
  interface ProviderOption {
@@ -16,6 +17,9 @@ interface RunTuiOptions {
16
17
  attachments: AttachmentStore;
17
18
  events: EventBus;
18
19
  tokenCounter?: TokenCounter;
20
+ visionAdapters?: VisionAdapters;
21
+ /** Resolve current model vision support. Falls back to provider capability when omitted. */
22
+ supportsVision?: () => boolean | Promise<boolean>;
19
23
  model: string;
20
24
  banner?: boolean;
21
25
  /** Persists the input queue across crashes; if omitted, the queue is in-memory only. */
package/dist/index.js CHANGED
@@ -1,117 +1,14 @@
1
1
  import { render, useApp, Box, useStdout, Static, Text, useInput, useStdin } from 'ink';
2
2
  import React, { useState, useReducer, useRef, useEffect, useMemo } from 'react';
3
- import * as fs from 'fs/promises';
4
- import * as path3 from 'path';
3
+ import * as fs2 from 'fs/promises';
4
+ import * as path2 from 'path';
5
5
  import { InputBuilder, formatTodosList } from '@wrongstack/core';
6
- import { spawn } from 'child_process';
7
- import * as os from 'os';
6
+ import { routeImagesForModel } from '@wrongstack/runtime/vision';
7
+ import { readClipboardImage } from '@wrongstack/runtime/clipboard';
8
8
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
9
+ import { spawn } from 'child_process';
9
10
 
10
11
  // src/run-tui.ts
11
- var MAX_IMAGE_BYTES = 10 * 1024 * 1024;
12
- async function readClipboardImage() {
13
- const platform = process.platform;
14
- if (platform === "win32") return readWindows();
15
- if (platform === "darwin") return readDarwin();
16
- if (platform === "linux") return readLinux();
17
- return null;
18
- }
19
- async function readWindows() {
20
- const tmp = path3.join(os.tmpdir(), `wstack-clip-${Date.now()}.png`);
21
- const ps = [
22
- "Add-Type -AssemblyName System.Windows.Forms",
23
- "Add-Type -AssemblyName System.Drawing",
24
- "$img = [System.Windows.Forms.Clipboard]::GetImage()",
25
- 'if ($img -eq $null) { Write-Output "NO_IMAGE"; exit 0 }',
26
- `$img.Save('${tmp.replace(/\\/g, "\\\\")}', [System.Drawing.Imaging.ImageFormat]::Png)`,
27
- 'Write-Output "OK"'
28
- ].join("; ");
29
- const out = await runCmd("powershell", ["-NoProfile", "-Command", ps]);
30
- if (!out || out.trim() === "NO_IMAGE") return null;
31
- if (!out.includes("OK")) return null;
32
- return readPngFile(tmp);
33
- }
34
- async function readDarwin() {
35
- const tmp = path3.join(os.tmpdir(), `wstack-clip-${Date.now()}.png`);
36
- const script = [
37
- "try",
38
- ` set the_file to (open for access POSIX file "${tmp}" with write permission)`,
39
- " write (the clipboard as \xABclass PNGf\xBB) to the_file",
40
- " close access the_file",
41
- "on error",
42
- " try",
43
- ' close access POSIX file "' + tmp + '"',
44
- " end try",
45
- ' return "NO_IMAGE"',
46
- "end try",
47
- 'return "OK"'
48
- ].join("\n");
49
- const out = await runCmd("osascript", ["-e", script]);
50
- if (!out || out.trim() !== "OK") return null;
51
- return readPngFile(tmp);
52
- }
53
- async function readLinux() {
54
- const tmp = path3.join(os.tmpdir(), `wstack-clip-${Date.now()}.png`);
55
- const tries = [
56
- ["wl-paste", ["--type", "image/png"]],
57
- ["xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]]
58
- ];
59
- for (const [cmd, args] of tries) {
60
- const ok = await runCmdToFile(cmd, args, tmp).catch(() => false);
61
- if (ok) return readPngFile(tmp);
62
- }
63
- return null;
64
- }
65
- async function readPngFile(p) {
66
- try {
67
- const buf = await fs.readFile(p);
68
- if (buf.length === 0) {
69
- await fs.unlink(p).catch(() => void 0);
70
- return null;
71
- }
72
- if (buf.length > MAX_IMAGE_BYTES) {
73
- await fs.unlink(p).catch(() => void 0);
74
- throw new Error(`Clipboard image exceeds ${MAX_IMAGE_BYTES / 1024 / 1024}MB limit`);
75
- }
76
- if (buf[0] !== 137 || buf[1] !== 80 || buf[2] !== 78 || buf[3] !== 71) {
77
- await fs.unlink(p).catch(() => void 0);
78
- return null;
79
- }
80
- await fs.unlink(p).catch(() => void 0);
81
- return { base64: buf.toString("base64"), mediaType: "image/png", bytes: buf.length };
82
- } catch (err) {
83
- if (err.code === "ENOENT") return null;
84
- throw err;
85
- }
86
- }
87
- function runCmd(cmd, args) {
88
- return new Promise((resolve) => {
89
- const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
90
- let out = "";
91
- child.stdout.on("data", (c) => {
92
- out += String(c);
93
- });
94
- child.on("error", () => resolve(null));
95
- child.on("exit", (code) => resolve(code === 0 ? out : null));
96
- });
97
- }
98
- function runCmdToFile(cmd, args, outPath) {
99
- return new Promise((resolve) => {
100
- const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
101
- const chunks = [];
102
- child.stdout.on("data", (c) => chunks.push(c));
103
- child.on("error", () => resolve(false));
104
- child.on("exit", async (code) => {
105
- if (code !== 0 || chunks.length === 0) return resolve(false);
106
- try {
107
- await fs.writeFile(outPath, Buffer.concat(chunks));
108
- resolve(true);
109
- } catch {
110
- resolve(false);
111
- }
112
- });
113
- });
114
- }
115
12
  function stringifyInput(input) {
116
13
  if (!input || typeof input !== "object") return "";
117
14
  const obj = input;
@@ -214,8 +111,8 @@ function FilePicker({ query, matches, selected }) {
214
111
  ] }, m))
215
112
  ] });
216
113
  }
217
- function highlight(path4, query) {
218
- return path4;
114
+ function highlight(path3, query) {
115
+ return path3;
219
116
  }
220
117
  var STATUS_ICON = {
221
118
  idle: { icon: "\u25CB", color: "gray" },
@@ -1974,10 +1871,10 @@ async function loadIndex(root) {
1974
1871
  async function walk(root, rel, depth, out) {
1975
1872
  if (out.length >= MAX_FILES_INDEXED) return;
1976
1873
  if (depth > MAX_DEPTH) return;
1977
- const dir = rel ? path3.join(root, rel) : root;
1874
+ const dir = rel ? path2.join(root, rel) : root;
1978
1875
  let entries;
1979
1876
  try {
1980
- entries = await fs.readdir(dir, { withFileTypes: true });
1877
+ entries = await fs2.readdir(dir, { withFileTypes: true });
1981
1878
  } catch {
1982
1879
  return;
1983
1880
  }
@@ -2667,6 +2564,8 @@ function App({
2667
2564
  attachments,
2668
2565
  events,
2669
2566
  tokenCounter,
2567
+ visionAdapters = [],
2568
+ supportsVision,
2670
2569
  model,
2671
2570
  banner = true,
2672
2571
  queueStore,
@@ -2742,8 +2641,8 @@ function App({
2742
2641
  const lastEnterAtRef = useRef(0);
2743
2642
  const projectRoot = agent.ctx.projectRoot;
2744
2643
  const projectName = React.useMemo(() => {
2745
- const base = path3.basename(projectRoot);
2746
- return base && base !== path3.sep ? base : void 0;
2644
+ const base = path2.basename(projectRoot);
2645
+ return base && base !== path2.sep ? base : void 0;
2747
2646
  }, [projectRoot]);
2748
2647
  const streamingTextRef = useRef("");
2749
2648
  const pendingDeltaRef = useRef("");
@@ -2853,7 +2752,7 @@ function App({
2853
2752
  let cancelled = false;
2854
2753
  const poll = async () => {
2855
2754
  try {
2856
- const data = await fs.readFile(planPath, "utf8");
2755
+ const data = await fs2.readFile(planPath, "utf8");
2857
2756
  const parsed = JSON.parse(data);
2858
2757
  if (cancelled) return;
2859
2758
  if (!Array.isArray(parsed.items)) {
@@ -2985,9 +2884,9 @@ function App({
2985
2884
  dispatch({ type: "pickerClose" });
2986
2885
  return;
2987
2886
  }
2988
- const absPath = path3.isAbsolute(picked) ? picked : path3.join(projectRoot, picked);
2887
+ const absPath = path2.isAbsolute(picked) ? picked : path2.join(projectRoot, picked);
2989
2888
  try {
2990
- const data = await fs.readFile(absPath, "utf8");
2889
+ const data = await fs2.readFile(absPath, "utf8");
2991
2890
  const placeholder = await builder.appendFile({
2992
2891
  kind: "file",
2993
2892
  data,
@@ -3900,7 +3799,24 @@ function App({
3900
3799
  const startedAt = Date.now();
3901
3800
  const before = tokenCounter?.total();
3902
3801
  const costBefore = tokenCounter?.estimateCost().total ?? 0;
3903
- const result = await agent.run(blocks, { signal: ctrl.signal });
3802
+ const routed = blocks.some((block) => block.type === "image") ? await routeImagesForModel(blocks, {
3803
+ supportsVision: supportsVision ? await supportsVision() : agent.ctx.provider.capabilities.vision,
3804
+ adapters: visionAdapters,
3805
+ ctx: agent.ctx,
3806
+ signal: ctrl.signal,
3807
+ providerId: agent.ctx.provider.id,
3808
+ model: agent.ctx.model
3809
+ }) : { blocks, route: "none", convertedImages: 0 };
3810
+ if (routed.route === "adapter") {
3811
+ dispatch({
3812
+ type: "addEntry",
3813
+ entry: {
3814
+ kind: "info",
3815
+ text: `Image input analyzed via ${routed.adapterName ?? "vision adapter"} (${routed.convertedImages} image${routed.convertedImages === 1 ? "" : "s"}).`
3816
+ }
3817
+ });
3818
+ }
3819
+ const result = await agent.run(routed.blocks, { signal: ctrl.signal });
3904
3820
  const lingering = streamingTextRef.current;
3905
3821
  if (lingering.trim()) {
3906
3822
  dispatch({ type: "addEntry", entry: { kind: "assistant", text: lingering } });
@@ -3953,11 +3869,19 @@ function App({
3953
3869
  await runBlocks(head.blocks);
3954
3870
  }
3955
3871
  };
3872
+ const runBlocksRef = useRef(runBlocks);
3873
+ runBlocksRef.current = runBlocks;
3956
3874
  const submit = async () => {
3957
3875
  const raw = state.buffer;
3958
3876
  const trimmed = raw.trim();
3959
3877
  if (!trimmed && state.placeholders.length === 0) return;
3960
3878
  dispatch({ type: "resetInterrupts" });
3879
+ if (trimmed === "/image" || trimmed === "/paste-image") {
3880
+ dispatch({ type: "clearInput" });
3881
+ await pasteClipboardImage();
3882
+ if (state.historyIndex > 0) dispatch({ type: "historyPush", text: trimmed });
3883
+ return;
3884
+ }
3961
3885
  if (trimmed.startsWith("/")) {
3962
3886
  dispatch({ type: "addEntry", entry: { kind: "user", text: trimmed } });
3963
3887
  if (state.historyIndex > 0) dispatch({ type: "historyPush", text: trimmed });
@@ -4050,9 +3974,9 @@ function App({
4050
3974
  b.appendText(ask);
4051
3975
  }
4052
3976
  const blocks = await b.submit();
4053
- await runBlocks(blocks);
3977
+ await runBlocksRef.current(blocks);
4054
3978
  })();
4055
- }, []);
3979
+ }, [initialAsk, initialGoal]);
4056
3980
  const inputHint = useMemo(() => {
4057
3981
  if (state.status !== "idle") return "";
4058
3982
  if (state.buffer.startsWith("/")) return "slash command \u2014 Enter to dispatch";
@@ -4253,6 +4177,8 @@ async function runTui(opts) {
4253
4177
  attachments: opts.attachments,
4254
4178
  events: opts.events,
4255
4179
  tokenCounter: opts.tokenCounter,
4180
+ visionAdapters: opts.visionAdapters,
4181
+ supportsVision: opts.supportsVision,
4256
4182
  model: opts.model,
4257
4183
  banner: opts.banner ?? true,
4258
4184
  queueStore: opts.queueStore,