moqi-tui 0.2.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.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +782 -0
  3. package/bin/moqi.mjs +40 -0
  4. package/cordis.patch.yml +41 -0
  5. package/lib/cross-find.js +217 -0
  6. package/lib/file-index.js +121 -0
  7. package/lib/fleet-sources.js +114 -0
  8. package/lib/index.js +3999 -0
  9. package/lib/persist.js +194 -0
  10. package/lib/plugins.js +371 -0
  11. package/lib/presence.js +144 -0
  12. package/lib/rename.js +35 -0
  13. package/lib/rewind.js +94 -0
  14. package/lib/sessions-store.js +134 -0
  15. package/lib/startup.js +92 -0
  16. package/lib/tui/atfile.js +154 -0
  17. package/lib/tui/export.js +48 -0
  18. package/lib/tui/fleet.js +346 -0
  19. package/lib/tui/i18n.js +201 -0
  20. package/lib/tui/jobs.js +65 -0
  21. package/lib/tui/keys.js +205 -0
  22. package/lib/tui/markdown.js +368 -0
  23. package/lib/tui/mcp.js +95 -0
  24. package/lib/tui/panels.js +231 -0
  25. package/lib/tui/screen.js +156 -0
  26. package/lib/tui/state.js +502 -0
  27. package/lib/tui/stream.js +109 -0
  28. package/lib/tui/text.js +173 -0
  29. package/lib/tui/theme.js +183 -0
  30. package/lib/tui/themes.js +153 -0
  31. package/lib/tui/tooldetail.js +140 -0
  32. package/lib/tui/view.js +830 -0
  33. package/lib/tui/vim.js +222 -0
  34. package/lib/tui-host-core.js +141 -0
  35. package/lib/tui-host.js +48 -0
  36. package/lib/types/cross-find.d.ts +66 -0
  37. package/lib/types/file-index.d.ts +34 -0
  38. package/lib/types/fleet-sources.d.ts +34 -0
  39. package/lib/types/index.d.ts +51 -0
  40. package/lib/types/persist.d.ts +116 -0
  41. package/lib/types/plugins.d.ts +218 -0
  42. package/lib/types/presence.d.ts +48 -0
  43. package/lib/types/rename.d.ts +32 -0
  44. package/lib/types/rewind.d.ts +75 -0
  45. package/lib/types/sessions-store.d.ts +46 -0
  46. package/lib/types/startup.d.ts +45 -0
  47. package/lib/types/tui/atfile.d.ts +90 -0
  48. package/lib/types/tui/export.d.ts +18 -0
  49. package/lib/types/tui/fleet.d.ts +209 -0
  50. package/lib/types/tui/i18n.d.ts +34 -0
  51. package/lib/types/tui/jobs.d.ts +28 -0
  52. package/lib/types/tui/keys.d.ts +52 -0
  53. package/lib/types/tui/markdown.d.ts +14 -0
  54. package/lib/types/tui/mcp.d.ts +34 -0
  55. package/lib/types/tui/panels.d.ts +125 -0
  56. package/lib/types/tui/screen.d.ts +79 -0
  57. package/lib/types/tui/state.d.ts +323 -0
  58. package/lib/types/tui/stream.d.ts +78 -0
  59. package/lib/types/tui/text.d.ts +28 -0
  60. package/lib/types/tui/theme.d.ts +87 -0
  61. package/lib/types/tui/themes.d.ts +70 -0
  62. package/lib/types/tui/tooldetail.d.ts +45 -0
  63. package/lib/types/tui/view.d.ts +163 -0
  64. package/lib/types/tui/vim.d.ts +64 -0
  65. package/lib/types/tui-host-core.d.ts +62 -0
  66. package/lib/types/tui-host.d.ts +42 -0
  67. package/lib/types/version.d.ts +8 -0
  68. package/lib/types/voice.d.ts +227 -0
  69. package/lib/version.js +32 -0
  70. package/lib/voice.js +405 -0
  71. package/package.json +119 -0
  72. package/scripts/harness-root.mjs +88 -0
  73. package/scripts/install-profile.mjs +133 -0
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Small durable state for the terminal app: composer history, UI
3
+ * preferences, and the sessions that were open, kept as one JSON file under
4
+ * `$DSH_HOME`.
5
+ *
6
+ * Everything here is best-effort by design. The app must run on a read-only
7
+ * or missing home just as well as on a writable one — persistence is a
8
+ * convenience, never a dependency.
9
+ * @module moqi-tui/persist
10
+ */
11
+ /**
12
+ * One session that was open when the app last exited.
13
+ *
14
+ * Only the id is load-bearing — the transcript itself lives in the Harness
15
+ * session store and is re-read on restore. The model and title are carried
16
+ * along so the tab bar and footer read correctly the instant the app paints,
17
+ * rather than snapping into place once each agent has been adopted.
18
+ */
19
+ export interface PersistedSession {
20
+ /** The Harness session id to re-adopt. */
21
+ id: string;
22
+ /** Label of the model that session was using; empty means "use the default". */
23
+ model: string;
24
+ /** The tab's title, normally the opening prompt; empty until one is sent. */
25
+ title: string;
26
+ }
27
+ /** What survives a restart of the app. */
28
+ export interface PersistedState {
29
+ /** Previously sent prompts, oldest first, for composer recall. */
30
+ inputHistory: string[];
31
+ /** Whether reasoning output was visible when the app last ran. */
32
+ thinking: boolean;
33
+ /** Name of the chosen color palette; absent means the app's default. */
34
+ theme?: string;
35
+ /** Interface language: `en` or `zh-CN`. */
36
+ lang?: string;
37
+ /**
38
+ * Whether tool calls were listed rather than summarized when the app last
39
+ * ran; absent means the default, which is to list them.
40
+ */
41
+ expandTools?: boolean;
42
+ /** Devices to include in the fleet overview, as `ssh` destinations. */
43
+ peers: string[];
44
+ /** Sessions that were open at the last exit, in tab order. */
45
+ sessions: PersistedSession[];
46
+ /** Index into {@link PersistedState.sessions} of the tab that was on screen. */
47
+ activeSession: number;
48
+ }
49
+ /**
50
+ * How many sessions a single restore will bring back.
51
+ *
52
+ * Every restored tab costs one session-store lookup and one agent adoption,
53
+ * both of them I/O before the first paint. A state file that has grown a long
54
+ * tail — or been hand-edited — must not turn a launch into a multi-second
55
+ * stall, and nobody navigates more tabs than this by hand anyway.
56
+ */
57
+ export declare const MAX_RESTORED_SESSIONS = 16;
58
+ /** Where the state file lives: `$DSH_HOME/tui-state.json`, default `~/.dsh`. */
59
+ export declare function statePath(env?: NodeJS.ProcessEnv): string;
60
+ /**
61
+ * Turn the raw file contents into state, without touching the filesystem.
62
+ *
63
+ * Parsing is separated from reading so the whole degrade-gracefully contract
64
+ * — bad JSON, a version from another release, a half-written array of
65
+ * sessions — can be exercised as a pure function. It never throws: an input
66
+ * it cannot make sense of yields the fallback, which is always usable.
67
+ */
68
+ export declare function decodeState(raw: string): PersistedState;
69
+ /**
70
+ * Read the persisted state, returning the fallback when there is none or it
71
+ * cannot be understood.
72
+ */
73
+ export declare function loadState(env?: NodeJS.ProcessEnv): Promise<PersistedState>;
74
+ /** The sessions to bring back, and which of them to put on screen. */
75
+ export interface RestorePlan {
76
+ /** Sessions worth adopting, in tab order; may be empty. */
77
+ sessions: PersistedSession[];
78
+ /** Index into {@link RestorePlan.sessions} to make active. */
79
+ active: number;
80
+ }
81
+ /**
82
+ * Decide what a restore should attempt, given what is still on disk.
83
+ *
84
+ * The session store is not owned by this app: `/delete` prunes it, and so does
85
+ * anything else that touches `$DSH_HOME` between two runs. A remembered id
86
+ * whose directory has gone is therefore an ordinary outcome and not an error —
87
+ * it is dropped here, silently, before anything tries to adopt it.
88
+ *
89
+ * @param state - what was read back from disk.
90
+ * @param isAvailable - whether that session id still exists in the store.
91
+ */
92
+ export declare function restorePlan(state: PersistedState, isAvailable: (id: string) => boolean): RestorePlan;
93
+ /**
94
+ * A scratch path for the write-then-rename, unique to this process.
95
+ *
96
+ * The rename is what makes a save atomic, but the file it renames has to be
97
+ * this process's alone. Several sessions of this app run at once -- that is
98
+ * the point of the fleet -- and when two of them saved at the same moment they
99
+ * wrote the same `tui-state.json.tmp` on top of each other and renamed the
100
+ * interleaved result into place. The file that came out was one complete
101
+ * document followed by a fragment of another, which every later read then
102
+ * discarded as unparseable, silently losing the remembered sessions, peers and
103
+ * theme. Observed, not hypothetical.
104
+ */
105
+ export declare function scratchPath(target: string): string;
106
+ /**
107
+ * Write the state atomically: a temporary file in the same directory, then a
108
+ * rename, so a crash mid-write can never leave a half-written JSON behind.
109
+ */
110
+ export declare function saveState(state: PersistedState, env?: NodeJS.ProcessEnv): Promise<void>;
111
+ /**
112
+ * The synchronous twin of {@link saveState}, for the teardown path: `quit()`
113
+ * asks the launcher to exit immediately, so an in-flight async write would be
114
+ * cut off and the last prompt lost.
115
+ */
116
+ export declare function saveStateSync(state: PersistedState, env?: NodeJS.ProcessEnv): void;
@@ -0,0 +1,218 @@
1
+ /**
2
+ * The plugin set of the profile this app booted from.
3
+ *
4
+ * A Harness profile is a directory under `$DSH_HOME/profiles/<name>` whose
5
+ * `package.json` carries two lists that are easy to confuse: `dependencies`,
6
+ * which is what pnpm put on disk, and `dsh.profile.bundles`, the ordered
7
+ * layer stack the launcher actually composes. A package can sit in the first
8
+ * and not the second, and that gap is exactly what this app calls enabled and
9
+ * disabled — which is why the picker reads a manifest rather than asking the
10
+ * package manager what is installed.
11
+ *
12
+ * Everything that only reads or rewrites that manifest is a pure function
13
+ * over a parsed object, because the alternative is a feature whose only test
14
+ * is a network install. The two operations that cannot be pure — adding and
15
+ * removing a package — are one narrow `execFile` at the bottom of the file.
16
+ *
17
+ * Nothing here takes effect until the app is restarted: the bundle sets
18
+ * `patchReload: "startup"`, so a layer list edited under a running terminal is
19
+ * read at the next launch and not before.
20
+ * @module moqi-tui/plugins
21
+ */
22
+ /** The layer every profile composes first; nothing else works without it. */
23
+ export declare const BASE_BUNDLE = "@deepseek-ai/dsh-base";
24
+ /** This app's own package, as the profile's dependency list spells it. */
25
+ export declare const APP_PACKAGE = "moqi-tui";
26
+ /**
27
+ * The identifier this package used immediately before `moqi-tui`.
28
+ *
29
+ * A profile installed under the old name still lists it, and the app has to
30
+ * keep recognizing its own bundle as protected — otherwise the plugin pane
31
+ * offers to disable the terminal it is running inside. npm rejected the bare
32
+ * `moqi` as too close to existing packages, so the published name grew a
33
+ * suffix while the command stayed `moqi`.
34
+ */
35
+ export declare const LEGACY_APP_PACKAGE = "moqi";
36
+ /**
37
+ * Packages the picker lists but refuses to change.
38
+ *
39
+ * Disabling the base layer leaves a profile with no agent, and disabling this
40
+ * app removes the very screen the command was typed on — in both cases the
41
+ * only way back is to hand-edit JSON, so the app does not offer the rope.
42
+ */
43
+ export declare const PROTECTED_PACKAGES: readonly string[];
44
+ /** The package manager profiles are installed with. */
45
+ export declare const PACKAGE_MANAGER = "pnpm";
46
+ /** The `dsh.profile` section of a profile manifest. */
47
+ export interface ProfileSection {
48
+ /** The ordered layer stack, by package name. */
49
+ bundles?: string[];
50
+ /** When the launcher re-reads the user patch file; this bundle sets `startup`. */
51
+ patchReload?: string;
52
+ /** Anything else the Harness writes there, preserved on the way back out. */
53
+ [key: string]: unknown;
54
+ }
55
+ /** The `dsh` section of a profile manifest. */
56
+ export interface DshSection {
57
+ profile?: ProfileSection;
58
+ /** Anything else the Harness writes there, preserved on the way back out. */
59
+ [key: string]: unknown;
60
+ }
61
+ /**
62
+ * A profile's `package.json`, as far as this app cares.
63
+ *
64
+ * The index signature is load-bearing rather than lazy typing: the manifest is
65
+ * the user's file and carries fields this app has no business knowing about,
66
+ * and a rewrite that dropped them would be a silent data loss.
67
+ */
68
+ export interface ProfileManifest {
69
+ dependencies?: Record<string, string>;
70
+ dsh?: DshSection;
71
+ [key: string]: unknown;
72
+ }
73
+ /** One package of the active profile, as the picker shows it. */
74
+ export interface PluginEntry {
75
+ /** The package name, exactly as the manifest spells it. */
76
+ name: string;
77
+ /** The dependency spec it was installed from; empty for an in-box layer. */
78
+ spec: string;
79
+ /** Whether the profile installed it, as opposed to inheriting it in-box. */
80
+ installed: boolean;
81
+ /** Whether it is in `dsh.profile.bundles`, i.e. composed into the app. */
82
+ enabled: boolean;
83
+ /** Whether this app refuses to change it; see {@link PROTECTED_PACKAGES}. */
84
+ protected: boolean;
85
+ }
86
+ /** The outcome of an edit to the layer stack. */
87
+ export interface BundleEdit {
88
+ /** The manifest to write back; the original when nothing moved. */
89
+ manifest: ProfileManifest;
90
+ /** Whether the layer stack actually changed. */
91
+ changed: boolean;
92
+ /** Why it did not, in a form the status line can print; empty when it did. */
93
+ reason: string;
94
+ }
95
+ /** A package name, with whatever version suffix came with it. */
96
+ export interface PackageRequest {
97
+ /** The bare package name, without a version. */
98
+ name: string;
99
+ /** The single argument handed to the package manager. */
100
+ spec: string;
101
+ }
102
+ /** Either a package the app is willing to install, or why it is not. */
103
+ export type PackageRequestResult = {
104
+ ok: true;
105
+ request: PackageRequest;
106
+ } | {
107
+ ok: false;
108
+ reason: string;
109
+ };
110
+ /** How a package-manager run turned out. */
111
+ export interface PackageManagerRun {
112
+ ok: boolean;
113
+ /** One line for the status bar: the manager's own last word, or the failure. */
114
+ message: string;
115
+ }
116
+ /** Whether this app declines to enable or disable a package. */
117
+ export declare function isProtected(packageName: string): boolean;
118
+ /** Where a profile of this name lives under a given Harness home. */
119
+ export declare function resolveProfileDir(profileName: string, dshHome: string): string;
120
+ /**
121
+ * Which profile this process booted, read back off the launcher's own argv.
122
+ *
123
+ * The launcher strips `--profile` before the plugin tree mounts and hands the
124
+ * app only what followed it, so `ctx.cmdlineArgs` cannot answer this and no
125
+ * environment variable carries it either. `process.argv` is untouched, though,
126
+ * and the flag is still sitting in it.
127
+ * @param argv - a launcher command line; `process.argv` by default.
128
+ * @returns the profile name, or undefined when the app was not launched that way.
129
+ */
130
+ export declare function activeProfileName(argv?: readonly string[]): string | undefined;
131
+ /**
132
+ * Read a profile's manifest, or nothing at all.
133
+ *
134
+ * A profile directory that is missing, unreadable, or holding something other
135
+ * than a JSON object is reported as "no plugins" rather than as an error: the
136
+ * app has to keep running on a home it cannot read, and a broken manifest is
137
+ * not something the terminal can fix anyway.
138
+ * @param dir - the profile directory from {@link resolveProfileDir}.
139
+ */
140
+ export declare function readProfileManifest(dir: string): ProfileManifest | undefined;
141
+ /**
142
+ * Write a profile's manifest back.
143
+ *
144
+ * Two-space JSON with a trailing newline, which is what both the Harness's own
145
+ * `writeProfileManifest` and this repo's install script emit — matching them
146
+ * keeps an app-side edit out of the diff a user takes of their own profile.
147
+ * @param dir - the profile directory from {@link resolveProfileDir}.
148
+ * @param manifest - the manifest value to persist.
149
+ */
150
+ export declare function writeProfileManifest(dir: string, manifest: ProfileManifest): void;
151
+ /**
152
+ * Every package of a profile, enabled ones first in composition order.
153
+ *
154
+ * The two lists are unioned rather than intersected because each holds names
155
+ * the other does not: an in-box layer like the base bundle is composed without
156
+ * ever being a dependency, and a package can be installed with no layer of its
157
+ * own. Enabled rows keep their `bundles` order, since that order is what the
158
+ * launcher applies; the rest are alphabetical, having no order to preserve.
159
+ * @param manifest - a parsed manifest, or undefined for an unreadable profile.
160
+ */
161
+ export declare function listPlugins(manifest: ProfileManifest | undefined): PluginEntry[];
162
+ /** Whether a package is currently composed into the app. */
163
+ export declare function isPluginEnabled(manifest: ProfileManifest | undefined, packageName: string): boolean;
164
+ /**
165
+ * Add a package to the layer stack, or take it out of it.
166
+ *
167
+ * Enabling appends. Patch layers compose in order and the last one to touch a
168
+ * row wins, so a plugin somebody just turned on should be able to override
169
+ * what was already there rather than be overridden by it. The base layer is
170
+ * then pulled back to the front, because everything else patches over it.
171
+ *
172
+ * Nothing is installed or deleted here — the package stays exactly where the
173
+ * package manager left it, and only the composed stack moves.
174
+ * @param manifest - the profile manifest to edit.
175
+ * @param packageName - the dependency to enable or disable.
176
+ * @param enabled - the membership wanted.
177
+ * @returns the manifest to write, or the original plus the reason it did not move.
178
+ */
179
+ export declare function setPluginEnabled(manifest: ProfileManifest | undefined, packageName: string, enabled: boolean): BundleEdit;
180
+ /**
181
+ * Drop a package from the layer stack whatever its protection, for use on the
182
+ * way to removing it from disk.
183
+ *
184
+ * A name left in `dsh.profile.bundles` after its package is gone is not a
185
+ * cosmetic leftover: the launcher fails loud on a listed bundle it cannot
186
+ * resolve, so the profile would stop booting. The stack is therefore edited
187
+ * first and the package manager run second — an interrupted removal then
188
+ * leaves a package that is merely disabled, which still starts.
189
+ * @param manifest - the profile manifest to edit.
190
+ * @param packageName - the dependency about to be removed.
191
+ */
192
+ export declare function forgetPlugin(manifest: ProfileManifest | undefined, packageName: string): BundleEdit;
193
+ /**
194
+ * Decide whether a typed string may be handed to the package manager.
195
+ *
196
+ * There is no registry search behind this app, so the string is whatever
197
+ * somebody typed, and it becomes one argument of a spawned process. The
198
+ * grammar is therefore checked rather than escaped: a name that is not a name
199
+ * is refused with a reason instead of being quoted and hoped about.
200
+ * @param input - the raw `/plugins add` argument.
201
+ */
202
+ export declare function parsePackageRequest(input: string): PackageRequestResult;
203
+ /**
204
+ * Run the package manager in a profile directory.
205
+ *
206
+ * pnpm and not npm: a profile's dependency on a local checkout is a `link:`
207
+ * spec, which npm rewrites into its own idea of a link and then loses on the
208
+ * next install — so the Harness installs profiles with pnpm and this app has
209
+ * to agree with it or it would quietly break the very profile it is editing.
210
+ *
211
+ * The run never throws and never reaches the terminal: output is piped, not
212
+ * inherited, because the app owns the alternate screen and a package manager's
213
+ * progress bars drawn into it would corrupt the frame. A failure comes back as
214
+ * a line for the status bar.
215
+ * @param dir - the profile directory to run in.
216
+ * @param args - the manager's arguments, already validated.
217
+ */
218
+ export declare function runPackageManager(dir: string, args: readonly string[]): Promise<PackageManagerRun>;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Publishing what this device is doing, for the fleet overview.
3
+ *
4
+ * Each open session gets one small JSON file under `$DSH_HOME/tui-presence`,
5
+ * refreshed on a heartbeat and deleted on exit. The file's age is the liveness
6
+ * signal — `session.lock` in the session store is not, because it is an empty
7
+ * flock target that outlives the process that made it.
8
+ *
9
+ * Nothing here listens on a port. The records are ordinary files, read by
10
+ * another device over SSH, so the fleet overview adds no network surface and
11
+ * no credentials of its own.
12
+ * @module
13
+ */
14
+ import { type PresenceRecord, type PresenceStatus } from './tui/fleet.ts';
15
+ /** Directory holding this device's presence records. */
16
+ export declare function presenceDir(dshHome: string): string;
17
+ /** What the app tells the publisher about one session. */
18
+ export interface PresenceInput {
19
+ sessionId: string;
20
+ title: string;
21
+ status: PresenceStatus;
22
+ model?: string;
23
+ cwd?: string;
24
+ }
25
+ /**
26
+ * Writes and refreshes this device's presence records.
27
+ *
28
+ * The publisher is deliberately forgiving: a read-only or missing home must
29
+ * degrade to publishing nothing, never to failing a turn. The overview is a
30
+ * convenience, and a device that cannot publish simply does not appear.
31
+ */
32
+ export declare class PresencePublisher {
33
+ private readonly dir;
34
+ private readonly host;
35
+ private readonly owned;
36
+ private timer;
37
+ private snapshot;
38
+ private disabled;
39
+ constructor(dshHome: string, host?: string);
40
+ /** Begin heartbeating. `intervalMs` must be well under the stale threshold. */
41
+ start(intervalMs?: number): void;
42
+ /** Publish the current set of sessions, replacing whatever was there. */
43
+ publish(sessions: readonly PresenceInput[]): void;
44
+ /** Remove every record this process published. Safe to call repeatedly. */
45
+ stop(): void;
46
+ }
47
+ /** Read every presence record in a directory, skipping anything malformed. */
48
+ export declare function readPresenceDir(dir: string): PresenceRecord[];
@@ -0,0 +1,32 @@
1
+ /**
2
+ * `/rename` decided before any service is touched.
3
+ *
4
+ * The command has two outcomes — pin a user title or regenerate the automatic
5
+ * one — and which one applies depends only on the text after the command
6
+ * name. Deciding here keeps `index.ts` down to wiring, so the suite can cover
7
+ * every branch without a Harness behind it.
8
+ *
9
+ * @module moqi-tui/rename
10
+ */
11
+ /** What `/rename <request>` should do. */
12
+ export type RenamePlan = {
13
+ kind: 'pin';
14
+ title: string;
15
+ } | {
16
+ kind: 'refresh';
17
+ };
18
+ /**
19
+ * Classify one `/rename` request. Whitespace folds to single spaces — a
20
+ * session name is one line — surrounding space is dropped, and a request that
21
+ * says nothing asks for the automatic title to be regenerated (the service's
22
+ * documented unpin) rather than erroring on a stray space.
23
+ */
24
+ export declare function planRename(request: string): RenamePlan;
25
+ /**
26
+ * Read the title a `session/title` snapshot carries, defensively: the value
27
+ * crosses the plugin boundary from a service this build may shade, so nothing
28
+ * is assumed beyond "an object with a non-empty string `title`".
29
+ *
30
+ * @returns the snapshot's title, or `undefined` when it carries none.
31
+ */
32
+ export declare function snapshotTitle(snapshot: unknown): string | undefined;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Rewind and fork arithmetic over a session log.
3
+ *
4
+ * A fork must be a *balanced completed-turn prefix*: contiguous from seq 0,
5
+ * ending between turns, with no open turn, step, or dangling tool call. This
6
+ * module finds those boundaries from the event types alone, so the rules are
7
+ * testable without a live session.
8
+ * @module
9
+ */
10
+ /** The little bit of an event this module needs, plus its message payload. */
11
+ export interface MessageEventLike extends LogEventLike {
12
+ data?: {
13
+ message?: {
14
+ content?: unknown;
15
+ };
16
+ };
17
+ }
18
+ /** The little bit of an event this module needs. */
19
+ export interface LogEventLike {
20
+ seq: number;
21
+ type: string;
22
+ }
23
+ /** One human prompt in the log, with the turn it opened. */
24
+ export interface UserTurn {
25
+ /** Seq of the `user/message` event. */
26
+ seq: number;
27
+ /** The prompt text, for the picker. */
28
+ text: string;
29
+ /** Seq of the `turn/start` that opened the containing turn. */
30
+ turnStartSeq: number | undefined;
31
+ }
32
+ /**
33
+ * Every human prompt in log order, each remembering the turn that carried it.
34
+ *
35
+ * A `user/message` outside any turn (a resumed or repaired log) keeps
36
+ * `turnStartSeq: undefined`, which makes it unrewindable rather than guessed.
37
+ */
38
+ export declare function projectUserTurns(events: readonly MessageEventLike[]): UserTurn[];
39
+ /**
40
+ * Where to cut the log to rewind to a chosen prompt.
41
+ *
42
+ * Rewinding means "take me back to just before this prompt was sent", so the
43
+ * cut is the start of the turn that contains it — everything before that turn
44
+ * is the seed, and the prompt itself returns to the composer.
45
+ *
46
+ * @returns the exclusive cut offset and the prompt text, or `undefined` when
47
+ * the rewind is impossible: no turn boundary, or the boundary is the very
48
+ * start of the log (rewinding past the first message leaves nothing).
49
+ */
50
+ export declare function rewindTarget(turns: readonly UserTurn[], chosenIndex: number): {
51
+ cutSeq: number;
52
+ text: string;
53
+ } | undefined;
54
+ /**
55
+ * The exclusive cut offset for a full fork: after the last completed turn.
56
+ *
57
+ * A fork of an idle session keeps every completed turn; a log with an open
58
+ * turn at the end is cut back to the last `turn/end`, because a fork may not
59
+ * inherit a half-finished turn.
60
+ *
61
+ * @returns the exclusive offset, or 0 when there is no completed turn yet.
62
+ */
63
+ export declare function forkCut(events: readonly LogEventLike[], endSeq: number): number;
64
+ /**
65
+ * The lineage of a session id inside a set of known sessions, oldest ancestor
66
+ * first, for `/tree`.
67
+ */
68
+ export declare function lineage(sessions: readonly {
69
+ id: string;
70
+ parentSession?: string;
71
+ title?: string;
72
+ }[], id: string): {
73
+ id: string;
74
+ title?: string;
75
+ }[];
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Where the JSONL session store keeps sessions on disk, and how to remove one.
3
+ *
4
+ * The harness has no public delete API: storage is append-only by design and
5
+ * the query index reconciles against the filesystem, dropping entries whose
6
+ * session directory has gone. Deleting the directory is therefore the whole
7
+ * operation — and the reason a session stays stored until the user asks.
8
+ *
9
+ * The path encoding here mirrors `dsh-session-persistence-jsonl` exactly:
10
+ * `$DSH_HOME/sessions/<projectKey(cwd)>/<encodeSegment(id)>/`.
11
+ * @module
12
+ */
13
+ /** The sessions root: `$DSH_HOME/sessions`, defaulting to `~/.dsh/sessions`. */
14
+ export declare function sessionsRoot(): string;
15
+ /**
16
+ * Encode one path segment: safe characters pass through, everything else
17
+ * becomes `~XXXX` with the code unit in upper-case hex. `.`
18
+ * and `..` are always escaped so a session id can never traverse.
19
+ */
20
+ export declare function encodeSegment(raw: string): string;
21
+ /**
22
+ * The readable directory key for a project path. Separators fold to `-`, the
23
+ * result is bounded to a filesystem component, and the name always carries the
24
+ * leading `--`/trailing `--` fence so a project directory is recognizable.
25
+ */
26
+ export declare function projectKey(cwd: string): string;
27
+ /** The directory one stored session owns, given the cwd it was created with. */
28
+ export declare function storedSessionDir(cwd: string, id: string): string;
29
+ /**
30
+ * Find a session's directory by id alone, scanning the project directories.
31
+ *
32
+ * The picker knows an id but not always the cwd it was created under, and the
33
+ * encoded id segment is unique across projects, so a scan is both correct and
34
+ * cheap: one `readdir` of the root plus one per project directory.
35
+ */
36
+ export declare function findStoredSessionDir(id: string): Promise<string | undefined>;
37
+ /**
38
+ * Delete a stored session from disk. The query index notices on its next
39
+ * reconciliation pass, so the session disappears from `/resume` too.
40
+ *
41
+ * @returns `true` when something was removed, `false` when no such session
42
+ * was stored.
43
+ */
44
+ export declare function deleteStoredSession(cwd: string, id: string): Promise<boolean>;
45
+ /** Delete a session found by id through {@link findStoredSessionDir}. */
46
+ export declare function deleteStoredSessionDir(dir: string): Promise<boolean>;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The terminal app's command-line provider.
3
+ *
4
+ * It parses this app's own flags out of the shared immutable cmdline snapshot
5
+ * and publishes them as a service, so the app row can consume them lazily —
6
+ * the same shape the shipped headless bundle uses.
7
+ * @module moqi-tui/startup
8
+ */
9
+ import type { Context } from '@deepseek-ai/cordis';
10
+ /** Stable Cordis plugin name. */
11
+ export declare const name = "tui-startup";
12
+ /** Services required before the flags can be resolved. */
13
+ export declare const inject: string[];
14
+ /** Service key provided by this plugin and injected by the app row. */
15
+ export declare const TUI_STARTUP_SERVICE = "tuiStartup";
16
+ /** What the app row reads from {@link TUI_STARTUP_SERVICE}. */
17
+ export interface TuiStartupValues {
18
+ /** Exact session to adopt on launch; absent starts a fresh one. */
19
+ resumeSessionId: string | undefined;
20
+ /** Model override for this run; absent uses the profile's default. */
21
+ model: string | undefined;
22
+ /** Whether reasoning output starts visible. */
23
+ thinking: boolean;
24
+ /** Context budget override; absent means use the model's own capacity. */
25
+ contextLimit: number | undefined;
26
+ /** Report mouse events so the wheel scrolls; off by default. */
27
+ mouse: boolean;
28
+ /** Ring the bell when a session's turn finishes; on by default. */
29
+ bell: boolean;
30
+ /** Bring back the sessions that were open at the last exit; on by default. */
31
+ restore: boolean;
32
+ /** Devices to include in the fleet overview; empty means this one only. */
33
+ peers: string[];
34
+ /** Whisper weights for push-to-talk; absent falls back to the default search. */
35
+ voiceModel: string | undefined;
36
+ /** Whisper executable for push-to-talk; absent looks for the known names. */
37
+ voiceBin: string | undefined;
38
+ /** Peer profile `/dispatch` boots; the shipped `headless` one by default. */
39
+ dispatchProfile: string | undefined;
40
+ }
41
+ /**
42
+ * Parse this app's flags and provide them as an ordinary Cordis service.
43
+ * @param ctx - plugin context carrying the command line.
44
+ */
45
+ export declare function apply(ctx: Context): void;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * `@` file completion: token detection, fuzzy filtering, and the inline menu.
3
+ *
4
+ * Like the rest of `tui/`, this module knows nothing about the terminal or the
5
+ * Harness — it turns composer text plus a candidate list into a menu state, so
6
+ * the interaction rules stay testable on their own.
7
+ * @module
8
+ */
9
+ /** Whether a path names an image the composer can stage as an attachment. */
10
+ export declare function isImagePath(path: string): boolean;
11
+ /** Directories a workspace walk never descends into. */
12
+ export declare const SKIP_DIRECTORIES: Set<string>;
13
+ /**
14
+ * The `@` token being typed at the cursor, if any.
15
+ *
16
+ * A token counts only when the `@` sits at a token boundary — the start of
17
+ * the text or right after whitespace — so an email address in prose
18
+ * (`user@host`) or a social handle never opens the menu. The query is what
19
+ * follows the `@` up to the cursor; a query containing `/` is path-shaped and
20
+ * lists one directory rather than fuzzy-matching the whole workspace.
21
+ */
22
+ export declare function activeAtToken(text: string, cursor: number): {
23
+ query: string;
24
+ start: number;
25
+ } | undefined;
26
+ /**
27
+ * Whether a query names a directory rather than fuzzy-matching the workspace:
28
+ * anything containing a separator (`src/`, `../lib`, `~/notes`).
29
+ */
30
+ export declare function isPathShaped(query: string): boolean;
31
+ export interface AtMatch {
32
+ /** Workspace-relative path, or a directory-relative one for path queries. */
33
+ path: string;
34
+ /** True when picking this entry descends into a directory. */
35
+ directory: boolean;
36
+ }
37
+ /**
38
+ * Rank candidates for a plain (non-path-shaped) query.
39
+ *
40
+ * Every path must contain the query as a subsequence, the same filter the
41
+ * model picker uses; shallower and shorter paths win so `state` finds
42
+ * `src/tui/state.ts` before `tests/theme-state-fixture.ts`.
43
+ */
44
+ export declare function filterFiles(query: string, paths: readonly string[], limit?: number): AtMatch[];
45
+ /**
46
+ * The inline completion menu over the active `@` token.
47
+ *
48
+ * It follows the composer rather than owning the keyboard: typing keeps
49
+ * filtering, `↑`/`↓` (or ctrl+p/n) move, `tab`/`enter` accept, `esc` dismisses
50
+ * — and only the menu closes, not anything layered beneath it.
51
+ */
52
+ export declare class AtMenu {
53
+ open: boolean;
54
+ matches: AtMatch[];
55
+ selected: number;
56
+ /** The token the menu is showing matches for; a change reopens it. */
57
+ query: string;
58
+ /** Recompute from the active token. `dismissed` is the token the user pressed esc on. */
59
+ update(token: {
60
+ query: string;
61
+ start: number;
62
+ } | undefined, candidates: readonly AtMatch[], dismissed: string | undefined): void;
63
+ move(delta: number): void;
64
+ current(): AtMatch | undefined;
65
+ close(): void;
66
+ }
67
+ /**
68
+ * Replace the token a menu pick stands for with the accepted path.
69
+ *
70
+ * Returns the new composer text and cursor: the `@` and everything typed
71
+ * after it up to the cursor give way to the path plus a trailing space, so
72
+ * the next word starts cleanly. Whatever followed the cursor stays.
73
+ */
74
+ export declare function acceptToken(text: string, cursor: number, token: {
75
+ start: number;
76
+ }, path: string): {
77
+ text: string;
78
+ cursor: number;
79
+ };
80
+ /**
81
+ * Extract `[Image #N name]` tokens from a draft.
82
+ *
83
+ * Returns the text with the tokens stripped and the numbers in order, so the
84
+ * sender can pair them with staged attachment references. A token the user
85
+ * deleted leaves no trace: unmatched staged images are dropped at send.
86
+ */
87
+ export declare function extractImageTokens(text: string): {
88
+ text: string;
89
+ numbers: number[];
90
+ };