dsh-code 0.6.0 → 0.7.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/README.en.md +4 -2
- package/README.md +4 -2
- package/bin/deepseek.mjs +38 -3
- package/lib/index.mjs +1485 -906
- package/lib/startup.mjs +21 -9
- package/lib/theme-BEi4i_aN.mjs +624 -0
- package/lib/types/app.d.ts +31 -3
- package/lib/types/index.d.ts +1 -0
- package/lib/types/kernel-panels.d.ts +21 -0
- package/lib/types/mentions.d.ts +29 -12
- package/lib/types/models.d.ts +66 -0
- package/lib/types/render/animations.d.ts +175 -2
- package/lib/types/render/projection.d.ts +38 -7
- package/lib/types/render/status.d.ts +34 -13
- package/lib/types/startup.d.ts +12 -4
- package/lib/types/theme-panel.d.ts +24 -0
- package/lib/types/theme.d.ts +158 -2
- package/package.json +1 -1
- package/src/app.ts +510 -130
- package/src/index.ts +964 -900
- package/src/kernel-panels.ts +481 -419
- package/src/mentions.ts +57 -27
- package/src/models.ts +200 -66
- package/src/render/animations.ts +359 -2
- package/src/render/projection.ts +764 -655
- package/src/render/status.ts +744 -603
- package/src/startup.ts +119 -109
- package/src/theme-panel.ts +72 -0
- package/src/theme.ts +206 -70
package/src/index.ts
CHANGED
|
@@ -1,900 +1,964 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @deepseek-ai/dsh-code — the interactive terminal driver. The bundle patch
|
|
3
|
-
* rides over dsh-base without Host, HTTP, or browser plugins; this runner
|
|
4
|
-
* creates or resumes preset-composed Agents through the core registry, keeps
|
|
5
|
-
* one Ink owner while the active session changes, folds submitted prompts
|
|
6
|
-
* into the selected durable session, answers approval asks with a y/n bar,
|
|
7
|
-
* dispatches slash commands, and on quit flushes and requests process exit.
|
|
8
|
-
*
|
|
9
|
-
* @module @deepseek-ai/dsh-code
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { randomUUID } from 'node:crypto'
|
|
13
|
-
import { readFileSync } from 'node:fs'
|
|
14
|
-
import { homedir } from 'node:os'
|
|
15
|
-
import { mkdir, writeFile as writeFileAsync } from 'node:fs/promises'
|
|
16
|
-
import { basename, dirname, join } from 'node:path'
|
|
17
|
-
import { createElement } from 'react'
|
|
18
|
-
import type { Context } from '@deepseek-ai/cordis'
|
|
19
|
-
import z from '@deepseek-ai/schemastery'
|
|
20
|
-
import { installModelSelection } from '@deepseek-ai/dsh-agent'
|
|
21
|
-
import type { Agent, AgentHandle, ModelSelection, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
|
|
22
|
-
import type {} from '@deepseek-ai/dsh-agent-default-model'
|
|
23
|
-
import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
|
|
24
|
-
import { SessionId, type Session, type SessionEvent, type SessionHeader, type UserMessage } from '@deepseek-ai/dsh-session'
|
|
25
|
-
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
|
26
|
-
// Type-only: carries the ctx.sessionTitle service merge for /title.
|
|
27
|
-
import type {} from '@deepseek-ai/dsh-session-title'
|
|
28
|
-
// Empty type imports carry the loader Context merge for the settlement await
|
|
29
|
-
// and the cmdline Context merge for the appExit host value.
|
|
30
|
-
import type {} from '@deepseek-ai/cordis-plugin-loader'
|
|
31
|
-
import type {} from '@deepseek-ai/dsh-cmdline'
|
|
32
|
-
import { App, type NoticeTone } from './app.ts'
|
|
33
|
-
import { mountApprovalAnswerer, type ApprovalStore } from './approval.ts'
|
|
34
|
-
import { isSlashLine, watchCommands, type CommandsView } from './commands.ts'
|
|
35
|
-
import { internals, type TuiMount } from './internals.ts'
|
|
36
|
-
import { loadModelDirectory, type ModelRow } from './models.ts'
|
|
37
|
-
import { createMentions, type
|
|
38
|
-
import { mountQuestionProvider, type QuestionStore } from './questions.ts'
|
|
39
|
-
import { createTranscriptStore, type TranscriptStore } from './store.ts'
|
|
40
|
-
import { parseStatuslineItems } from './render/status.ts'
|
|
41
|
-
import { appendHistoryContent, HISTORY_MAX_ENTRIES, parseHistoryFile } from './history.ts'
|
|
42
|
-
import { watchSkills, type SkillsView } from './skills.ts'
|
|
43
|
-
import { toolArgumentsPreview } from './render/tool-preview.ts'
|
|
44
|
-
import { buildExportMarkdown } from './render/export.ts'
|
|
45
|
-
import type { TuiStartup } from './startup.ts'
|
|
46
|
-
import { SessionSwitchQueue } from './session-switch.ts'
|
|
47
|
-
import { agentPresetsFrom, resolvePreset, switchPreset } from './presets.ts'
|
|
48
|
-
import { listPluginRows } from './plugin-inventory.ts'
|
|
49
|
-
import {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
type
|
|
54
|
-
type
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
*
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
*
|
|
117
|
-
* @param
|
|
118
|
-
* @
|
|
119
|
-
* @
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
* @param
|
|
153
|
-
* @
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
* @param
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
const
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
//
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
let
|
|
268
|
-
let
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
// the
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
const
|
|
295
|
-
if (agent !== undefined)
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
//
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
//
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
})
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
//
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
if (
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
const
|
|
424
|
-
if (
|
|
425
|
-
|
|
426
|
-
return
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
//
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
*
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
try {
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
} catch (error: unknown) {
|
|
637
|
-
bridge.notify(`
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
if (
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
const
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
const
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
const
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
const
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
:
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @deepseek-ai/dsh-code — the interactive terminal driver. The bundle patch
|
|
3
|
+
* rides over dsh-base without Host, HTTP, or browser plugins; this runner
|
|
4
|
+
* creates or resumes preset-composed Agents through the core registry, keeps
|
|
5
|
+
* one Ink owner while the active session changes, folds submitted prompts
|
|
6
|
+
* into the selected durable session, answers approval asks with a y/n bar,
|
|
7
|
+
* dispatches slash commands, and on quit flushes and requests process exit.
|
|
8
|
+
*
|
|
9
|
+
* @module @deepseek-ai/dsh-code
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { randomUUID } from 'node:crypto'
|
|
13
|
+
import { readFileSync } from 'node:fs'
|
|
14
|
+
import { homedir } from 'node:os'
|
|
15
|
+
import { mkdir, writeFile as writeFileAsync } from 'node:fs/promises'
|
|
16
|
+
import { basename, dirname, join } from 'node:path'
|
|
17
|
+
import { createElement } from 'react'
|
|
18
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
19
|
+
import z from '@deepseek-ai/schemastery'
|
|
20
|
+
import { installModelSelection } from '@deepseek-ai/dsh-agent'
|
|
21
|
+
import type { Agent, AgentHandle, ModelSelection, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
|
|
22
|
+
import type {} from '@deepseek-ai/dsh-agent-default-model'
|
|
23
|
+
import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
|
|
24
|
+
import { SessionId, type Session, type SessionEvent, type SessionHeader, type UserMessage } from '@deepseek-ai/dsh-session'
|
|
25
|
+
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
|
26
|
+
// Type-only: carries the ctx.sessionTitle service merge for /title.
|
|
27
|
+
import type {} from '@deepseek-ai/dsh-session-title'
|
|
28
|
+
// Empty type imports carry the loader Context merge for the settlement await
|
|
29
|
+
// and the cmdline Context merge for the appExit host value.
|
|
30
|
+
import type {} from '@deepseek-ai/cordis-plugin-loader'
|
|
31
|
+
import type {} from '@deepseek-ai/dsh-cmdline'
|
|
32
|
+
import { App, type NoticeTone } from './app.ts'
|
|
33
|
+
import { mountApprovalAnswerer, type ApprovalStore } from './approval.ts'
|
|
34
|
+
import { isSlashLine, watchCommands, type CommandsView } from './commands.ts'
|
|
35
|
+
import { internals, type TuiMount } from './internals.ts'
|
|
36
|
+
import { buildModelSelection, loadModelDirectory, resolveEffectiveSelection, type ModelRow } from './models.ts'
|
|
37
|
+
import { createMentions, type MentionsApi } from './mentions.ts'
|
|
38
|
+
import { mountQuestionProvider, type QuestionStore } from './questions.ts'
|
|
39
|
+
import { createTranscriptStore, type TranscriptStore } from './store.ts'
|
|
40
|
+
import { parseStatuslineItems } from './render/status.ts'
|
|
41
|
+
import { appendHistoryContent, HISTORY_MAX_ENTRIES, parseHistoryFile } from './history.ts'
|
|
42
|
+
import { watchSkills, type SkillsView } from './skills.ts'
|
|
43
|
+
import { toolArgumentsPreview } from './render/tool-preview.ts'
|
|
44
|
+
import { buildExportMarkdown } from './render/export.ts'
|
|
45
|
+
import type { TuiStartup } from './startup.ts'
|
|
46
|
+
import { SessionSwitchQueue } from './session-switch.ts'
|
|
47
|
+
import { agentPresetsFrom, resolvePreset, switchPreset } from './presets.ts'
|
|
48
|
+
import { listPluginRows } from './plugin-inventory.ts'
|
|
49
|
+
import { parseThemeName, setTheme, type ThemeName } from './theme.ts'
|
|
50
|
+
import {
|
|
51
|
+
mergeSessionTitles,
|
|
52
|
+
projectSessionRows,
|
|
53
|
+
type SessionDirectoryOptions,
|
|
54
|
+
type SessionQueryService,
|
|
55
|
+
type SessionRow,
|
|
56
|
+
} from './session-directory.ts'
|
|
57
|
+
|
|
58
|
+
/** Stable Cordis plugin name. */
|
|
59
|
+
export const name = 'tui-runner'
|
|
60
|
+
|
|
61
|
+
/** Core services required before the interactive session can start. */
|
|
62
|
+
export const inject = ['agentDefaultModel', 'agents', 'sessions']
|
|
63
|
+
|
|
64
|
+
/** Plugin config: the startup resolved from this app's injected provider service. */
|
|
65
|
+
export interface Config {
|
|
66
|
+
/** How this invocation obtains its session identity (validated loosely; narrowed in {@link apply}). */
|
|
67
|
+
startup: { kind: string; sessionId?: string; mode?: string; theme?: string }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const Config: z<Config> = z.object({
|
|
71
|
+
startup: z.object({
|
|
72
|
+
kind: z.string().required(),
|
|
73
|
+
sessionId: z.string(),
|
|
74
|
+
mode: z.string(),
|
|
75
|
+
theme: z.string(),
|
|
76
|
+
}),
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
/** Process-facing effects of the runner: the Ink mount plus the launcher's exit request. */
|
|
80
|
+
interface TuiIo {
|
|
81
|
+
mount: typeof internals.mount
|
|
82
|
+
exit(code: number): void
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Report an unexpected direct-driver failure and request a failing exit. */
|
|
86
|
+
function fail(io: TuiIo, error: unknown): void {
|
|
87
|
+
internals.stderr.write(`dsh: ${error instanceof Error ? error.message : String(error)}\n`)
|
|
88
|
+
io.exit(1)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Resolve the working directory's git branch for the status line.
|
|
93
|
+
* @param cwd - the session's working directory.
|
|
94
|
+
* @returns the branch name, or '' outside a repository or on a detached HEAD.
|
|
95
|
+
*/
|
|
96
|
+
function gitBranch(cwd: string): string {
|
|
97
|
+
try {
|
|
98
|
+
const ref = readFileSync(join(cwd, '.git', 'HEAD'), 'utf8').trim().match(/^ref: refs\/heads\/(.+)$/)
|
|
99
|
+
return ref?.[1] ?? ''
|
|
100
|
+
} catch {
|
|
101
|
+
// Only the single HEAD read is attempted, so the sole reachable failure is
|
|
102
|
+
// a missing repository (or unreadable HEAD file): the branch group drops out.
|
|
103
|
+
return ''
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The session identity this invocation will run, plus whether it is resumed. */
|
|
108
|
+
interface Target {
|
|
109
|
+
sessionId: string
|
|
110
|
+
resume: boolean
|
|
111
|
+
mode?: string
|
|
112
|
+
cwd?: string
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Resolve the invocation's target session against the persisted headers.
|
|
117
|
+
* @param startup - the parsed startup flags.
|
|
118
|
+
* @param persistence - the persistence service; required for resume/latest.
|
|
119
|
+
* @param cwd - the working directory `--continue` filters by.
|
|
120
|
+
* @returns the target identity.
|
|
121
|
+
* @throws with a user-facing message when the flags name nothing resolvable.
|
|
122
|
+
*/
|
|
123
|
+
async function resolveTarget(startup: TuiStartup, persistence: SessionPersistence | undefined, cwd: string): Promise<Target> {
|
|
124
|
+
if (startup.kind === 'fresh') return { sessionId: `session-${randomUUID()}`, resume: false, mode: startup.mode }
|
|
125
|
+
if (startup.kind === 'named') return { sessionId: startup.sessionId, resume: false, mode: startup.mode }
|
|
126
|
+
if (persistence === undefined) {
|
|
127
|
+
throw new Error('cannot resolve the requested session: session persistence is not configured')
|
|
128
|
+
}
|
|
129
|
+
const headers: readonly SessionHeader[] = await persistence.list()
|
|
130
|
+
if (startup.kind === 'resume') {
|
|
131
|
+
const wanted = startup.sessionId
|
|
132
|
+
const exact = headers.filter(header => header.id === wanted)
|
|
133
|
+
const matches = exact.length > 0 ? exact : headers.filter(header => header.id.startsWith(wanted))
|
|
134
|
+
if (matches.length === 0) throw new Error(`no persisted session matches "${wanted}"`)
|
|
135
|
+
if (matches.length > 1) {
|
|
136
|
+
throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches): use more of the id`)
|
|
137
|
+
}
|
|
138
|
+
return { sessionId: matches[0]!.id, resume: true }
|
|
139
|
+
}
|
|
140
|
+
// --continue: the newest persisted session whose header pins this cwd.
|
|
141
|
+
const local = headers
|
|
142
|
+
.filter(header => header.cwd === cwd)
|
|
143
|
+
.sort((left, right) => right.createdAt - left.createdAt)
|
|
144
|
+
if (local.length === 0) throw new Error(`no persisted session for this directory (${cwd}); start one without --continue`)
|
|
145
|
+
return { sessionId: local[0]!.id, resume: true }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Resolve a bounded command preview for one pending approval: the request
|
|
150
|
+
* contract carries no arguments, so the bar self-serves from the transcript
|
|
151
|
+
* projection via `callId` (mirrors the web ApprovalPanel's argsRaw lookup).
|
|
152
|
+
* @param events - the transcript entries to search.
|
|
153
|
+
* @param callId - the tool call the question is about, when the asker had one.
|
|
154
|
+
* @param toolName - the tool the question is about.
|
|
155
|
+
* @returns a bounded preview line, '' when nothing useful resolves.
|
|
156
|
+
*/
|
|
157
|
+
function approvalCommandPreview(events: readonly { kind: string }[], callId: string | undefined, toolName: string): string {
|
|
158
|
+
if (callId === undefined) return ''
|
|
159
|
+
const entry = events.find(candidate =>
|
|
160
|
+
candidate.kind === 'tool' && (candidate as { callId?: string }).callId === callId)
|
|
161
|
+
if (entry === undefined) return ''
|
|
162
|
+
const args = (entry as { arguments?: string }).arguments ?? ''
|
|
163
|
+
return toolArgumentsPreview(args, toolName)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The runner's connection between the React app and the process side. */
|
|
167
|
+
interface AppBridge {
|
|
168
|
+
/** Post one local notice line (feedback the transcript does not carry). */
|
|
169
|
+
notify(text: string, tone?: NoticeTone): void
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Run the interactive terminal session: resolve the target session, create or
|
|
174
|
+
* resume one Agent, mount the app, and keep the process alive until the user
|
|
175
|
+
* quits.
|
|
176
|
+
* @param ctx - plugin context carrying the Agent, default model, Session, and launcher IO services.
|
|
177
|
+
* @param startup - the parsed invocation flags.
|
|
178
|
+
* @param io - process-facing effects.
|
|
179
|
+
*/
|
|
180
|
+
async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void> {
|
|
181
|
+
// Loader siblings mount concurrently. Await the complete application before
|
|
182
|
+
// creating an Agent so its scoped tools and adapters are not half-composed.
|
|
183
|
+
await ctx.get('loader')?.await()
|
|
184
|
+
const agents = ctx.get('agents')
|
|
185
|
+
const defaultModel = ctx.get('agentDefaultModel')
|
|
186
|
+
const sessions = ctx.get('sessions')
|
|
187
|
+
const persistence = ctx.get('sessionPersistence')
|
|
188
|
+
const sessionQuery = (ctx as unknown as { get(name: string): unknown }).get('sessionQuery') as SessionQueryService | undefined
|
|
189
|
+
// Early process shutdown can dispose the tree while settlement is pending.
|
|
190
|
+
if (agents === undefined || defaultModel === undefined || sessions === undefined) return
|
|
191
|
+
|
|
192
|
+
const cwd = process.cwd()
|
|
193
|
+
const defaults = defaultModel.currentSelection()
|
|
194
|
+
const presets = agentPresetsFrom(ctx)
|
|
195
|
+
if (presets === undefined) throw new Error('agent preset service is unavailable; check the dsh-code bundle patch')
|
|
196
|
+
|
|
197
|
+
// A bare fresh launch stays transient: no Agent or session is composed, and
|
|
198
|
+
// nothing is persisted, until the user's first real input. Explicit flags
|
|
199
|
+
// (--resume/--continue/--session/--mode) keep the eager create/resume path.
|
|
200
|
+
const lazy = startup.kind === 'fresh' && startup.mode === undefined
|
|
201
|
+
|
|
202
|
+
interface ActiveSession {
|
|
203
|
+
handle: AgentHandle
|
|
204
|
+
agent: Agent
|
|
205
|
+
session: Session
|
|
206
|
+
store: ReturnType<typeof createTranscriptStore>
|
|
207
|
+
mentions: MentionsApi
|
|
208
|
+
mode: string
|
|
209
|
+
selection: { picked?: ModelSelection }
|
|
210
|
+
resumed: boolean
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Prepare a complete next session before disturbing the currently visible one. */
|
|
214
|
+
const prepare = async (next: Target): Promise<ActiveSession> => {
|
|
215
|
+
const nextCwd = next.cwd ?? cwd
|
|
216
|
+
// A bare launch can pick a model before any session exists: the process
|
|
217
|
+
// keeps that explicit choice and every prepared session starts from it
|
|
218
|
+
// (the documented precedence: explicit pick > session header > default).
|
|
219
|
+
const selectionState: { picked?: ModelSelection } = pendingSelection === undefined
|
|
220
|
+
? {}
|
|
221
|
+
: { picked: pendingSelection }
|
|
222
|
+
let mode = next.mode
|
|
223
|
+
if (!next.resume) mode = (await presets.resolve(mode)).id
|
|
224
|
+
const setup = async (agentCtx: Context): Promise<void> => {
|
|
225
|
+
const sessionPreset = next.resume
|
|
226
|
+
? resolvePreset(agentCtx.agent!.session)
|
|
227
|
+
: mode
|
|
228
|
+
const mounted = await presets.mount(agentCtx, sessionPreset)
|
|
229
|
+
mode = mounted.id
|
|
230
|
+
const selection: ModelSelectionRef = {
|
|
231
|
+
get current(): ModelSelection | undefined {
|
|
232
|
+
return resolveEffectiveSelection(selectionState.picked, agentCtx.agent?.session.requestHeader()?.config, defaults)
|
|
233
|
+
},
|
|
234
|
+
set current(value: ModelSelection | undefined) { selectionState.picked = value },
|
|
235
|
+
assembled: undefined,
|
|
236
|
+
}
|
|
237
|
+
installModelSelection(agentCtx, selection)
|
|
238
|
+
}
|
|
239
|
+
const handle = next.resume
|
|
240
|
+
? await agents.resume({
|
|
241
|
+
resumeSessionId: SessionId(next.sessionId),
|
|
242
|
+
agentOptions: { provider: defaults.provider, model: defaults.model },
|
|
243
|
+
setup,
|
|
244
|
+
})
|
|
245
|
+
: await agents.create({
|
|
246
|
+
sessionId: SessionId(next.sessionId),
|
|
247
|
+
meta: { cwd: nextCwd, agentPreset: mode },
|
|
248
|
+
agentOptions: { provider: defaults.provider, model: defaults.model },
|
|
249
|
+
setup,
|
|
250
|
+
})
|
|
251
|
+
const session = handle.agent.session
|
|
252
|
+
const sessionCwd = session.header.cwd ?? nextCwd
|
|
253
|
+
return {
|
|
254
|
+
handle,
|
|
255
|
+
agent: handle.agent,
|
|
256
|
+
session,
|
|
257
|
+
store: createTranscriptStore(session.events),
|
|
258
|
+
mentions: createMentions(ctx, handle.agent, sessionCwd),
|
|
259
|
+
mode: mode ?? 'standard',
|
|
260
|
+
selection: selectionState,
|
|
261
|
+
resumed: next.resume,
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
let active: ActiveSession | undefined
|
|
266
|
+
let agent: Agent | undefined
|
|
267
|
+
let session: Session | undefined
|
|
268
|
+
let store: TranscriptStore = createTranscriptStore()
|
|
269
|
+
// File-only mentions from the start: `@` completion works on a bare launch
|
|
270
|
+
// (no session yet); the prepare/activate paths replace this with the full
|
|
271
|
+
// agent-scoped instance that also resolves session references.
|
|
272
|
+
let mentions: MentionsApi = createMentions(ctx, undefined, cwd)
|
|
273
|
+
/** Explicit model pick made before any session exists (a bare launch). */
|
|
274
|
+
let pendingSelection: ModelSelection | undefined
|
|
275
|
+
|
|
276
|
+
if (!lazy) {
|
|
277
|
+
const target = await resolveTarget(startup, persistence, cwd)
|
|
278
|
+
const prepared = await prepare(target)
|
|
279
|
+
active = prepared
|
|
280
|
+
agent = prepared.agent
|
|
281
|
+
session = prepared.session
|
|
282
|
+
store = prepared.store
|
|
283
|
+
mentions = prepared.mentions
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Seed the transcript from the full session log: constructor seeds never
|
|
287
|
+
// fire on `session/event`, so a resumed session paints its history once
|
|
288
|
+
// before the first render. The handler reads the current session/store, so
|
|
289
|
+
// the deferred first session of a bare launch is covered by the same feed.
|
|
290
|
+
const off = ctx.on('session/event', (subject: Session, event: SessionEvent) => {
|
|
291
|
+
if (session !== undefined && subject.id === session.id) store.apply(event)
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
const commands: CommandsView = watchCommands(ctx)
|
|
295
|
+
if (agent !== undefined) commands.setAgent(agent)
|
|
296
|
+
|
|
297
|
+
const skills: SkillsView = watchSkills(ctx)
|
|
298
|
+
if (agent !== undefined) skills.setAgent(agent)
|
|
299
|
+
|
|
300
|
+
// Approval answerer: renders the ask as a y/n bar; only this TUI's agent is
|
|
301
|
+
// claimed, every other ask falls through to the fail-closed waterfall. The
|
|
302
|
+
// owner predicate is empty until the first session exists.
|
|
303
|
+
const approval: ApprovalStore = mountApprovalAnswerer(
|
|
304
|
+
ctx,
|
|
305
|
+
candidate => agent !== undefined && candidate.id === agent.id,
|
|
306
|
+
request => approvalCommandPreview(store.getView().entries, request.callId, request.toolName),
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
// ask_user_question provider: the single UI provider on the shared service,
|
|
310
|
+
// one request on screen at a time. Plan reviews (exit_plan_mode) arrive
|
|
311
|
+
// through this same pipe.
|
|
312
|
+
const questions: QuestionStore = mountQuestionProvider(ctx)
|
|
313
|
+
|
|
314
|
+
// The bridge the React app registers on mount: local notices from the
|
|
315
|
+
// process side (unknown commands, switch confirmations, cancels).
|
|
316
|
+
const bridge: AppBridge = { notify: () => {} }
|
|
317
|
+
|
|
318
|
+
// /statusline persistence: one user-level JSON file under the DSH home.
|
|
319
|
+
// Missing file means defaults; a corrupt file degrades to defaults with a
|
|
320
|
+
// surfaced warning (the customization is user-authored, never silent).
|
|
321
|
+
const statuslinePath = join(homedir(), '.dsh', 'dsh-code', 'statusline.json')
|
|
322
|
+
let statuslineWarning: string | undefined
|
|
323
|
+
let statuslineItems: readonly string[] = []
|
|
324
|
+
try {
|
|
325
|
+
statuslineItems = parseStatuslineItems(JSON.parse(readFileSync(statuslinePath, 'utf8')).items)
|
|
326
|
+
} catch (error) {
|
|
327
|
+
statuslineItems = parseStatuslineItems(undefined)
|
|
328
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
329
|
+
statuslineWarning = error instanceof Error ? error.message : String(error)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const saveStatusline = (items: readonly string[]): void => {
|
|
333
|
+
statuslineItems = [...items]
|
|
334
|
+
// The config directory may not exist on a first save; create it before
|
|
335
|
+
// the write so a fresh install persists customizations.
|
|
336
|
+
void mkdir(dirname(statuslinePath), { recursive: true })
|
|
337
|
+
.then(() => writeFileAsync(statuslinePath, JSON.stringify({ items }, null, 2) + '\n', 'utf8'))
|
|
338
|
+
.catch((writeError: unknown) => {
|
|
339
|
+
bridge.notify('statusline save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
|
|
340
|
+
})
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// /theme persistence: one user-level JSON file under the DSH home, mirroring
|
|
344
|
+
// the statusline file. A missing file means the dark default; a corrupt file
|
|
345
|
+
// degrades to dark with a surfaced warning. Precedence: CLI --theme > file >
|
|
346
|
+
// auto detection > dark (auto detection itself is a later enhancement and
|
|
347
|
+
// currently falls back to dark inside theme.ts).
|
|
348
|
+
const themePath = join(homedir(), '.dsh', 'dsh-code', 'theme.json')
|
|
349
|
+
let themeWarning: string | undefined
|
|
350
|
+
if (startup.theme === undefined) {
|
|
351
|
+
try {
|
|
352
|
+
setTheme(parseThemeName(JSON.parse(readFileSync(themePath, 'utf8')).theme))
|
|
353
|
+
} catch (error) {
|
|
354
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
355
|
+
themeWarning = error instanceof Error ? error.message : String(error)
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
} else {
|
|
359
|
+
setTheme(startup.theme)
|
|
360
|
+
}
|
|
361
|
+
const saveTheme = (name: ThemeName): void => {
|
|
362
|
+
setTheme(name)
|
|
363
|
+
void mkdir(dirname(themePath), { recursive: true })
|
|
364
|
+
.then(() => writeFileAsync(themePath, JSON.stringify({ theme: name }, null, 2) + '\n', 'utf8'))
|
|
365
|
+
.catch((writeError: unknown) => {
|
|
366
|
+
bridge.notify('theme save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
|
|
367
|
+
})
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Global input recall (Codex composer-history contract): one JSONL file
|
|
371
|
+
// under the DSH home. A missing file means an empty history; unreadable or
|
|
372
|
+
// corrupt content degrades to the valid lines it could parse, silently —
|
|
373
|
+
// recall is a convenience surface, never a gate.
|
|
374
|
+
const historyPath = join(homedir(), '.dsh', 'dsh-code', 'history.jsonl')
|
|
375
|
+
let inputHistory: readonly string[] = []
|
|
376
|
+
try {
|
|
377
|
+
inputHistory = parseHistoryFile(readFileSync(historyPath, 'utf8'))
|
|
378
|
+
} catch {
|
|
379
|
+
inputHistory = []
|
|
380
|
+
}
|
|
381
|
+
const recordHistory = (text: string): void => {
|
|
382
|
+
if (text === '') return
|
|
383
|
+
inputHistory = [...inputHistory, text].slice(-HISTORY_MAX_ENTRIES)
|
|
384
|
+
// A missing file on the first save is not an error: start from empty.
|
|
385
|
+
let current = ''
|
|
386
|
+
try {
|
|
387
|
+
current = readFileSync(historyPath, 'utf8')
|
|
388
|
+
} catch {
|
|
389
|
+
current = ''
|
|
390
|
+
}
|
|
391
|
+
void mkdir(dirname(historyPath), { recursive: true })
|
|
392
|
+
.then(() => writeFileAsync(historyPath, appendHistoryContent(current, text), 'utf8'))
|
|
393
|
+
.catch((writeError: unknown) => {
|
|
394
|
+
bridge.notify('history save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
|
|
395
|
+
})
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Cancel one queued inbox message (Delete on the empty composer); the durable splice retires its pending row. */
|
|
399
|
+
const cancelQueued = (messageId: string): void => {
|
|
400
|
+
if (agent === undefined) return
|
|
401
|
+
try {
|
|
402
|
+
if (agent.inbox.remove(MessageId(messageId))) {
|
|
403
|
+
bridge.notify('queued message cancelled')
|
|
404
|
+
}
|
|
405
|
+
} catch (error: unknown) {
|
|
406
|
+
bridge.notify('queue cancel failed: ' + (error instanceof Error ? error.message : String(error)), 'error')
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// The mount handle lives in a box: quit closes over it, while the mount
|
|
411
|
+
// itself is created after quit (the App element needs quit as a prop).
|
|
412
|
+
const mountRef: { current?: TuiMount } = {}
|
|
413
|
+
let quitting = false
|
|
414
|
+
const quit = (): void => {
|
|
415
|
+
if (quitting) return
|
|
416
|
+
quitting = true
|
|
417
|
+
switchQueue.cancel()
|
|
418
|
+
off()
|
|
419
|
+
mountRef.current?.unmount()
|
|
420
|
+
// A bare launch that exits before the first input has no session: exit
|
|
421
|
+
// cleanly without flushing or disposing anything.
|
|
422
|
+
const currentSession = session
|
|
423
|
+
const currentActive = active
|
|
424
|
+
if (currentSession === undefined || currentActive === undefined) {
|
|
425
|
+
io.exit(0)
|
|
426
|
+
return
|
|
427
|
+
}
|
|
428
|
+
void sessions.flush(currentSession)
|
|
429
|
+
.catch((flushError: unknown) => {
|
|
430
|
+
// The session log already carries every durable event; a failed flush
|
|
431
|
+
// must not trap the user in a dead terminal, so report and still exit.
|
|
432
|
+
internals.stderr.write(`dsh: session flush failed: ${flushError instanceof Error ? flushError.message : String(flushError)}\n`)
|
|
433
|
+
})
|
|
434
|
+
.then(() => currentActive.handle.dispose())
|
|
435
|
+
.catch((disposeError: unknown) => {
|
|
436
|
+
internals.stderr.write(`dsh: agent disposal failed: ${disposeError instanceof Error ? disposeError.message : String(disposeError)}\n`)
|
|
437
|
+
})
|
|
438
|
+
.then(() => { io.exit(0) })
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Run one slash line through the command registry (closed namespace). */
|
|
442
|
+
const runSlash = (line: string): void => {
|
|
443
|
+
const currentAgent = agent
|
|
444
|
+
if (currentAgent === undefined) return
|
|
445
|
+
if (line.startsWith('/mode ')) {
|
|
446
|
+
void switchModeAction(line.slice(6).trim())
|
|
447
|
+
return
|
|
448
|
+
}
|
|
449
|
+
if (line.startsWith('/resume ')) {
|
|
450
|
+
requestResume(line.slice(8).trim())
|
|
451
|
+
return
|
|
452
|
+
}
|
|
453
|
+
const registry = ctx.get('commands')
|
|
454
|
+
if (registry === undefined) {
|
|
455
|
+
bridge.notify('no command registry is mounted in this composition', 'error')
|
|
456
|
+
return
|
|
457
|
+
}
|
|
458
|
+
const controller = new AbortController()
|
|
459
|
+
void Promise.resolve().then(() => registry.execute(currentAgent, line, controller.signal)).then((execution) => {
|
|
460
|
+
if (execution === undefined) {
|
|
461
|
+
// No command owns this line: send it verbatim so a user-invocable
|
|
462
|
+
// skill gesture (`/skill-name`) reaches the host's tool-skill
|
|
463
|
+
// pre-step injection — the web composer's same fall-through.
|
|
464
|
+
try {
|
|
465
|
+
currentAgent.followup(createUserMessage({
|
|
466
|
+
content: [{ type: 'text', text: line }],
|
|
467
|
+
source: { kind: 'user' },
|
|
468
|
+
}))
|
|
469
|
+
} catch (error: unknown) {
|
|
470
|
+
bridge.notify(`command fallback failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}, (error: unknown) => {
|
|
474
|
+
bridge.notify(`command failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
475
|
+
})
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Deliver one trimmed line to the live session, expanding mentions first. */
|
|
479
|
+
const deliverLine = (line: string, mode: 'followup' | 'steer'): void => {
|
|
480
|
+
const currentAgent = agent!
|
|
481
|
+
const currentMentions = mentions!
|
|
482
|
+
// The command registry is a closed namespace: slash lines run out of
|
|
483
|
+
// band and never reach the model through this path (steering keeps the
|
|
484
|
+
// registry out of the inbox, so slash lines steer as literal text).
|
|
485
|
+
if (isSlashLine(line) && mode === 'followup') {
|
|
486
|
+
runSlash(line)
|
|
487
|
+
return
|
|
488
|
+
}
|
|
489
|
+
let parsed: ReturnType<MentionsApi['parse']>
|
|
490
|
+
try {
|
|
491
|
+
parsed = currentMentions.parse(line)
|
|
492
|
+
} catch (error: unknown) {
|
|
493
|
+
bridge.notify(`invalid session reference: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
494
|
+
return
|
|
495
|
+
}
|
|
496
|
+
const deliver = (readable: string, context?: UserMessage): void => {
|
|
497
|
+
// Session snapshots ride the inbox as model-facing context ahead of
|
|
498
|
+
// the readable message (upstream README wiring: inject before the
|
|
499
|
+
// followup/steer that wakes the driver).
|
|
500
|
+
try {
|
|
501
|
+
if (context !== undefined) currentAgent.inject(context)
|
|
502
|
+
const message = createUserMessage({
|
|
503
|
+
content: [{ type: 'text', text: readable }],
|
|
504
|
+
source: { kind: 'user' },
|
|
505
|
+
})
|
|
506
|
+
if (mode === 'steer') {
|
|
507
|
+
// The queued message is visible as a pending transcript row (the
|
|
508
|
+
// web queue-mirror contract); no notice noise on the happy path.
|
|
509
|
+
currentAgent.steer(message)
|
|
510
|
+
} else {
|
|
511
|
+
currentAgent.followup(message)
|
|
512
|
+
}
|
|
513
|
+
} catch (error: unknown) {
|
|
514
|
+
bridge.notify(`${mode === 'steer' ? 'steering' : 'message'} failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
if (parsed.references.length === 0) {
|
|
518
|
+
deliver(parsed.text)
|
|
519
|
+
return
|
|
520
|
+
}
|
|
521
|
+
const controller = new AbortController()
|
|
522
|
+
void currentMentions.prepare(parsed, controller.signal).then((prepared) => {
|
|
523
|
+
deliver(prepared.text, prepared.additionalContext)
|
|
524
|
+
}, (error: unknown) => {
|
|
525
|
+
if (controller.signal.aborted) return
|
|
526
|
+
bridge.notify(`session reference failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
527
|
+
})
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Deferred first-session creation for a bare launch: the session is composed
|
|
531
|
+
// only when the user submits real input (or /new), and every line that
|
|
532
|
+
// arrives during creation is delivered in order afterwards. A creation
|
|
533
|
+
// failure reports and clears the queue, leaving the transient state ready
|
|
534
|
+
// for the next attempt.
|
|
535
|
+
const pendingInputs: Array<{ text: string; mode: 'followup' | 'steer' }> = []
|
|
536
|
+
let creating: Promise<void> | undefined
|
|
537
|
+
const ensureSession = (mode?: string): void => {
|
|
538
|
+
if (creating !== undefined) return
|
|
539
|
+
const attempt = (async () => {
|
|
540
|
+
const next = await prepare({
|
|
541
|
+
sessionId: `session-${randomUUID()}`,
|
|
542
|
+
resume: false,
|
|
543
|
+
...(mode === undefined ? {} : { mode }),
|
|
544
|
+
})
|
|
545
|
+
if (quitting) {
|
|
546
|
+
void next.handle.dispose().catch(() => {})
|
|
547
|
+
return
|
|
548
|
+
}
|
|
549
|
+
active = next
|
|
550
|
+
agent = next.agent
|
|
551
|
+
session = next.session
|
|
552
|
+
store = next.store
|
|
553
|
+
mentions = next.mentions
|
|
554
|
+
commands.setAgent(agent)
|
|
555
|
+
skills.setAgent(agent)
|
|
556
|
+
// The App mounts with a placeholder key until the first input; the
|
|
557
|
+
// key-change remount below must start from a clean screen or the ghost
|
|
558
|
+
// static header stays visible above the new one (same source-backed
|
|
559
|
+
// clear the session-switch path performs).
|
|
560
|
+
process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
|
|
561
|
+
renderCurrent()
|
|
562
|
+
const queued = pendingInputs.splice(0)
|
|
563
|
+
for (const item of queued) deliverLine(item.text, item.mode)
|
|
564
|
+
})().catch((error: unknown) => {
|
|
565
|
+
pendingInputs.length = 0
|
|
566
|
+
bridge.notify(`session creation failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
567
|
+
}).finally(() => {
|
|
568
|
+
creating = undefined
|
|
569
|
+
})
|
|
570
|
+
creating = attempt
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** Deliver one readable line to the agent, expanding session mentions first. */
|
|
574
|
+
const send = (text: string, mode: 'followup' | 'steer'): void => {
|
|
575
|
+
const line = text.trim()
|
|
576
|
+
if (line === '') return
|
|
577
|
+
if (session === undefined) {
|
|
578
|
+
pendingInputs.push({ text: line, mode })
|
|
579
|
+
ensureSession()
|
|
580
|
+
return
|
|
581
|
+
}
|
|
582
|
+
deliverLine(line, mode)
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** Dispatch one submitted line: slash commands to the registry, other text to the agent. */
|
|
586
|
+
const dispatch = (text: string): void => {
|
|
587
|
+
send(text, 'followup')
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Submit steering: a running driver consumes the text at its next step
|
|
592
|
+
* boundary (the inbox delivers between steps); an idle driver just starts
|
|
593
|
+
* a turn, so this doubles as the busy-state submit path.
|
|
594
|
+
*/
|
|
595
|
+
const steer = (text: string): void => {
|
|
596
|
+
send(text, 'steer')
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Interrupt the running turn (Esc); true when a turn was actually cancelled. */
|
|
600
|
+
const interrupt = (): boolean => {
|
|
601
|
+
if (agent === undefined || agent.status !== 'running') return false
|
|
602
|
+
try {
|
|
603
|
+
agent.cancel({ kind: 'user' })
|
|
604
|
+
bridge.notify('turn cancelled — Ctrl+C or /quit to exit')
|
|
605
|
+
return true
|
|
606
|
+
} catch (error: unknown) {
|
|
607
|
+
bridge.notify(`cancel failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
608
|
+
return false
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Cycle to the next permission preset (Shift+Tab, the Claude-Code
|
|
614
|
+
* permission-mode convention mapped onto dsh presets). A session in a
|
|
615
|
+
* custom knob state wraps to the first declared preset.
|
|
616
|
+
*/
|
|
617
|
+
const cyclePermission = (): string => {
|
|
618
|
+
if (session === undefined) throw new Error('no session yet — submit a message to start')
|
|
619
|
+
const service = ctx.get('permissionPresets') as
|
|
620
|
+
| {
|
|
621
|
+
names: readonly string[]
|
|
622
|
+
current(events: readonly SessionEvent[]): string
|
|
623
|
+
set(target: Session, preset: string): void
|
|
624
|
+
}
|
|
625
|
+
| undefined
|
|
626
|
+
if (service === undefined || service.names.length === 0) {
|
|
627
|
+
bridge.notify('permission presets are not mounted in this composition', 'warning')
|
|
628
|
+
return ''
|
|
629
|
+
}
|
|
630
|
+
const at = service.names.indexOf(service.current(session.events))
|
|
631
|
+
const next = service.names[(at + 1) % service.names.length] ?? ''
|
|
632
|
+
if (next === '') return ''
|
|
633
|
+
try {
|
|
634
|
+
service.set(session, next)
|
|
635
|
+
return next
|
|
636
|
+
} catch (error: unknown) {
|
|
637
|
+
bridge.notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
638
|
+
return ''
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Apply one /model selection: takes effect from the next assembled step.
|
|
644
|
+
* The optional reasoning effort must be one the row advertises (the picker
|
|
645
|
+
* only offers those), so an unsupported value cannot reach the request
|
|
646
|
+
* pipeline; an absent effort restores the model's own default.
|
|
647
|
+
*/
|
|
648
|
+
const selectModel = (row: ModelRow, effortId?: string): string => {
|
|
649
|
+
const selection = buildModelSelection(row, effortId)
|
|
650
|
+
if (active === undefined) {
|
|
651
|
+
// A bare launch has no session yet: keep the pick process-wide so the
|
|
652
|
+
// first composed session starts from it.
|
|
653
|
+
pendingSelection = selection
|
|
654
|
+
} else {
|
|
655
|
+
active.selection.picked = selection
|
|
656
|
+
}
|
|
657
|
+
return `${row.provider}/${row.model}`
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Export the folded transcript to a markdown file (/export). The default
|
|
662
|
+
* target sits beside the session's cwd so the file lands in the user's
|
|
663
|
+
* workspace; an absolute or cwd-relative argument overrides it.
|
|
664
|
+
*/
|
|
665
|
+
const exportTranscript = async (argument: string): Promise<void> => {
|
|
666
|
+
if (session === undefined) {
|
|
667
|
+
bridge.notify('no session yet — submit a message to start', 'warning')
|
|
668
|
+
return
|
|
669
|
+
}
|
|
670
|
+
const wanted = argument.trim()
|
|
671
|
+
const sessionCwd = session.header.cwd ?? cwd
|
|
672
|
+
const defaultName = `dsh-session-${session.id.slice(-8)}.md`
|
|
673
|
+
const target = wanted === ''
|
|
674
|
+
? join(sessionCwd, defaultName)
|
|
675
|
+
: /^[a-zA-Z]:[\\/]/u.test(wanted) || wanted.startsWith('/')
|
|
676
|
+
? wanted
|
|
677
|
+
: join(sessionCwd, wanted)
|
|
678
|
+
const markdown = buildExportMarkdown(store.getView(), session.id)
|
|
679
|
+
try {
|
|
680
|
+
await writeFileAsync(target, `${markdown}\n`, 'utf8')
|
|
681
|
+
bridge.notify(`exported to ${target}`)
|
|
682
|
+
} catch (error: unknown) {
|
|
683
|
+
bridge.notify(`export failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Rename the session (/title): a user title pins the session and stops
|
|
689
|
+
* automatic generation (the service's own contract). The appended
|
|
690
|
+
* `session/title` event flows back through the store into the status line.
|
|
691
|
+
*/
|
|
692
|
+
const renameTitle = (argument: string): string => {
|
|
693
|
+
const title = argument.trim()
|
|
694
|
+
if (title === '') return 'usage: /title <text>'
|
|
695
|
+
if (session === undefined) return 'no session yet — submit a message to start'
|
|
696
|
+
const service = ctx.get('sessionTitle')
|
|
697
|
+
if (service === undefined) return 'session titles are unavailable in this profile'
|
|
698
|
+
try {
|
|
699
|
+
service.rename(session, title)
|
|
700
|
+
return `title → ${title}`
|
|
701
|
+
} catch (error: unknown) {
|
|
702
|
+
return `rename failed: ${error instanceof Error ? error.message : String(error)}`
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const loadSessions = async (options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]> => {
|
|
707
|
+
if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
|
|
708
|
+
const projected = projectSessionRows(await sessionQuery.listSessions(signal), options)
|
|
709
|
+
// Titles are the expensive fold. Fetch only the first bounded picker page;
|
|
710
|
+
// navigation/filter changes trigger a fresh, cancellable observation.
|
|
711
|
+
const page = projected.slice(0, 32)
|
|
712
|
+
if (page.length === 0) return projected
|
|
713
|
+
const observations = await sessionQuery.readTitleSnapshots(page.map(row => row.id), signal)
|
|
714
|
+
return mergeSessionTitles(projected, observations)
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const loadSessionTranscript = async (id: string, signal?: AbortSignal): Promise<string> => {
|
|
718
|
+
if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
|
|
719
|
+
const snapshot = await sessionQuery.readSession(id, signal)
|
|
720
|
+
return buildExportMarkdown(createTranscriptStore(snapshot.events).getView(), snapshot.session.id)
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const switchModeAction = async (id: string): Promise<string> => {
|
|
724
|
+
if (id === '') throw new Error('usage: /mode <preset>')
|
|
725
|
+
const currentAgent = agent
|
|
726
|
+
const currentActive = active
|
|
727
|
+
if (currentAgent === undefined || currentActive === undefined) {
|
|
728
|
+
throw new Error('no session yet — submit a message to start')
|
|
729
|
+
}
|
|
730
|
+
const preset = await switchPreset(presets, currentAgent, id)
|
|
731
|
+
currentActive.mode = preset.id
|
|
732
|
+
commands.setAgent(currentAgent)
|
|
733
|
+
skills.setAgent(currentAgent)
|
|
734
|
+
renderCurrent()
|
|
735
|
+
return preset.id
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
interface PendingSwitch { readonly target: Target; readonly label: string }
|
|
739
|
+
|
|
740
|
+
const activate = async (nextTarget: Target): Promise<void> => {
|
|
741
|
+
const previous = active
|
|
742
|
+
const next = await prepare(nextTarget)
|
|
743
|
+
active = next
|
|
744
|
+
agent = next.agent
|
|
745
|
+
session = next.session
|
|
746
|
+
store = next.store
|
|
747
|
+
mentions = next.mentions
|
|
748
|
+
commands.setAgent(agent)
|
|
749
|
+
skills.setAgent(agent)
|
|
750
|
+
try {
|
|
751
|
+
process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
|
|
752
|
+
renderCurrent()
|
|
753
|
+
} catch (error: unknown) {
|
|
754
|
+
active = previous
|
|
755
|
+
agent = previous?.agent
|
|
756
|
+
session = previous?.session
|
|
757
|
+
store = previous === undefined ? createTranscriptStore() : previous.store
|
|
758
|
+
mentions = previous === undefined ? createMentions(ctx, undefined, cwd) : previous.mentions
|
|
759
|
+
if (agent !== undefined) commands.setAgent(agent)
|
|
760
|
+
if (agent !== undefined) skills.setAgent(agent)
|
|
761
|
+
await next.handle.dispose()
|
|
762
|
+
renderCurrent()
|
|
763
|
+
throw error
|
|
764
|
+
}
|
|
765
|
+
// No previous session (a bare launch switched straight into a resume):
|
|
766
|
+
// nothing to flush or dispose, so just confirm the activation.
|
|
767
|
+
if (previous === undefined) {
|
|
768
|
+
bridge.notify(`${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`)
|
|
769
|
+
return
|
|
770
|
+
}
|
|
771
|
+
let cleanupWarning: string | undefined
|
|
772
|
+
try {
|
|
773
|
+
await sessions.flush(previous.session)
|
|
774
|
+
} catch (error: unknown) {
|
|
775
|
+
cleanupWarning = `previous session flush failed: ${error instanceof Error ? error.message : String(error)}`
|
|
776
|
+
}
|
|
777
|
+
try {
|
|
778
|
+
await previous.handle.dispose()
|
|
779
|
+
} catch (error: unknown) {
|
|
780
|
+
cleanupWarning = `${cleanupWarning === undefined ? '' : `${cleanupWarning}; `}previous agent release failed: ${error instanceof Error ? error.message : String(error)}`
|
|
781
|
+
}
|
|
782
|
+
bridge.notify(cleanupWarning === undefined
|
|
783
|
+
? `${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`
|
|
784
|
+
: `switched to ${next.session.id.slice(-12)}, but ${cleanupWarning}`,
|
|
785
|
+
cleanupWarning === undefined ? 'info' : 'warning')
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const switchQueue = new SessionSwitchQueue<PendingSwitch>(
|
|
789
|
+
async request => { if (!quitting) await activate(request.target) },
|
|
790
|
+
error => bridge.notify(`session switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error'),
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
const requestSwitch = (request: PendingSwitch): void => {
|
|
794
|
+
if (session === undefined) {
|
|
795
|
+
// No session yet (a bare launch using /resume before any input): activate
|
|
796
|
+
// the target directly — there is no running turn to wait on and nothing
|
|
797
|
+
// to flush.
|
|
798
|
+
void activate(request.target).catch((error: unknown) => {
|
|
799
|
+
bridge.notify(`session switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
800
|
+
})
|
|
801
|
+
return
|
|
802
|
+
}
|
|
803
|
+
if (request.target.sessionId === session.id) {
|
|
804
|
+
bridge.notify('that session is already active', 'warning')
|
|
805
|
+
return
|
|
806
|
+
}
|
|
807
|
+
const outcome = switchQueue.request(agent!, request)
|
|
808
|
+
if (outcome === 'queued') {
|
|
809
|
+
bridge.notify(`will switch to ${request.label} when the current turn finishes · /resume cancel to abort`)
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const resolveResumeId = async (wanted: string): Promise<string> => {
|
|
814
|
+
if (wanted === '') throw new Error('usage: /resume <id|prefix>')
|
|
815
|
+
if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
|
|
816
|
+
const records = await sessionQuery.listSessions()
|
|
817
|
+
const exact = records.filter(record => record.header.id === wanted)
|
|
818
|
+
const matches = exact.length > 0 ? exact : records.filter(record => record.header.id.startsWith(wanted))
|
|
819
|
+
if (matches.length === 0) throw new Error(`no session matches "${wanted}"`)
|
|
820
|
+
if (matches.length > 1) throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches)`)
|
|
821
|
+
if (matches[0]!.header.parentSession !== undefined || matches[0]!.header.origin === 'subagent') {
|
|
822
|
+
throw new Error('subagent conversations are read-only in /resume; resume a root session')
|
|
823
|
+
}
|
|
824
|
+
if (session !== undefined && agents.get(SessionId(matches[0]!.header.id)) !== undefined && matches[0]!.header.id !== session.id) {
|
|
825
|
+
throw new Error('that session is already live in another owner')
|
|
826
|
+
}
|
|
827
|
+
return matches[0]!.header.id
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const requestResume = (wanted: string): void => {
|
|
831
|
+
void resolveResumeId(wanted).then(id => {
|
|
832
|
+
requestSwitch({ target: { sessionId: id, resume: true }, label: id.slice(-12) })
|
|
833
|
+
}, (error: unknown) => bridge.notify(`resume failed: ${error instanceof Error ? error.message : String(error)}`, 'error'))
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const createSession = (mode?: string): void => {
|
|
837
|
+
// /new before any input is the first-session creation itself, not a switch.
|
|
838
|
+
if (session === undefined) {
|
|
839
|
+
ensureSession(mode)
|
|
840
|
+
return
|
|
841
|
+
}
|
|
842
|
+
const nextCwd = session.header.cwd ?? cwd
|
|
843
|
+
const id = `session-${randomUUID()}`
|
|
844
|
+
requestSwitch({ target: { sessionId: id, resume: false, mode, cwd: nextCwd }, label: id.slice(-12) })
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const switchSession = (row: SessionRow): void => {
|
|
848
|
+
if (!row.resumable) {
|
|
849
|
+
bridge.notify('subagent conversations are read-only', 'warning')
|
|
850
|
+
return
|
|
851
|
+
}
|
|
852
|
+
requestSwitch({ target: { sessionId: row.id, resume: true }, label: row.title ?? row.id.slice(-12) })
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const cancelSessionSwitch = (): boolean => {
|
|
856
|
+
return switchQueue.cancel()
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const appElement = (): ReturnType<typeof createElement> => {
|
|
860
|
+
// A bare launch mounts with placeholder facts until the first input
|
|
861
|
+
// composes a real session: empty session id/mode, the deployment default
|
|
862
|
+
// model, and the working directory's basename. `status.ts` drops empty
|
|
863
|
+
// mode/sessionId, so the bar renders only the identity it actually has.
|
|
864
|
+
const sessionCwd = session?.header.cwd ?? cwd
|
|
865
|
+
const model = store.getView().model !== ''
|
|
866
|
+
? store.getView().model
|
|
867
|
+
: pendingSelection !== undefined
|
|
868
|
+
? `${pendingSelection.provider}/${pendingSelection.model}`
|
|
869
|
+
: `${defaults.provider}/${defaults.model}`
|
|
870
|
+
const effort = resolveEffectiveSelection(
|
|
871
|
+
active?.selection.picked ?? pendingSelection,
|
|
872
|
+
session?.requestHeader()?.config,
|
|
873
|
+
defaults,
|
|
874
|
+
).reasoningEffort
|
|
875
|
+
return createElement(App, {
|
|
876
|
+
key: session?.id ?? 'pending',
|
|
877
|
+
store,
|
|
878
|
+
approval,
|
|
879
|
+
questions,
|
|
880
|
+
commands,
|
|
881
|
+
skills,
|
|
882
|
+
model,
|
|
883
|
+
effort,
|
|
884
|
+
cwd: basename(sessionCwd),
|
|
885
|
+
workspaceRoot: sessionCwd,
|
|
886
|
+
branch: gitBranch(sessionCwd),
|
|
887
|
+
sessionId: session === undefined ? '' : session.id.slice(-8),
|
|
888
|
+
resumed: active?.resumed ?? false,
|
|
889
|
+
mode: active?.mode ?? '',
|
|
890
|
+
dispatch,
|
|
891
|
+
steer,
|
|
892
|
+
interrupt,
|
|
893
|
+
quit,
|
|
894
|
+
loadModels: () => loadModelDirectory(ctx),
|
|
895
|
+
loadMentions: (query: string, signal?: AbortSignal) => mentions.candidates(query, signal),
|
|
896
|
+
cyclePermission,
|
|
897
|
+
selectModel,
|
|
898
|
+
exportTranscript,
|
|
899
|
+
renameTitle,
|
|
900
|
+
loadPresets: () => presets.list(),
|
|
901
|
+
switchMode: switchModeAction,
|
|
902
|
+
createSession,
|
|
903
|
+
loadSessions,
|
|
904
|
+
loadSessionTranscript,
|
|
905
|
+
switchSession,
|
|
906
|
+
cancelSessionSwitch,
|
|
907
|
+
loadPlugins: () => listPluginRows(ctx),
|
|
908
|
+
statusline: statuslineItems,
|
|
909
|
+
saveStatusline,
|
|
910
|
+
saveTheme,
|
|
911
|
+
history: inputHistory,
|
|
912
|
+
recordHistory,
|
|
913
|
+
cancelQueued,
|
|
914
|
+
onBridgeReady: (instance: AppBridge) => { bridge.notify = instance.notify },
|
|
915
|
+
})
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const renderCurrent = (): void => {
|
|
919
|
+
mountRef.current?.rerender(appElement())
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
mountRef.current = io.mount(appElement())
|
|
923
|
+
|
|
924
|
+
// A corrupt statusline config must not vanish silently: surface it once
|
|
925
|
+
// the notice channel is live, after the first frame settles.
|
|
926
|
+
if (statuslineWarning !== undefined) {
|
|
927
|
+
setTimeout(() => {
|
|
928
|
+
bridge.notify('statusline config unreadable, using defaults: ' + statuslineWarning, 'warning')
|
|
929
|
+
}, 50)
|
|
930
|
+
}
|
|
931
|
+
// Same one-shot surface for a corrupt theme file (dark fallback stays live).
|
|
932
|
+
if (themeWarning !== undefined) {
|
|
933
|
+
setTimeout(() => {
|
|
934
|
+
bridge.notify('theme config unreadable, using dark: ' + themeWarning, 'warning')
|
|
935
|
+
}, 50)
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* Mount the interactive terminal driver.
|
|
941
|
+
* @param ctx - plugin context carrying core services and the launcher-provided exit request.
|
|
942
|
+
* @param config - validated startup config resolved from the tuiStartup provider.
|
|
943
|
+
*/
|
|
944
|
+
export function apply(ctx: Context, config: Config): void {
|
|
945
|
+
// The CLI validated --theme at parse time; the loose config schema falls
|
|
946
|
+
// back to dark for anything unexpected.
|
|
947
|
+
const theme = config.startup.theme === undefined ? undefined : parseThemeName(config.startup.theme)
|
|
948
|
+
const startup: TuiStartup =
|
|
949
|
+
config.startup.kind === 'resume' && config.startup.sessionId !== undefined
|
|
950
|
+
? { kind: 'resume', sessionId: config.startup.sessionId, ...(theme === undefined ? {} : { theme }) }
|
|
951
|
+
: config.startup.kind === 'latest'
|
|
952
|
+
? { kind: 'latest', ...(theme === undefined ? {} : { theme }) }
|
|
953
|
+
: config.startup.kind === 'named' && config.startup.sessionId !== undefined
|
|
954
|
+
? { kind: 'named', sessionId: config.startup.sessionId, ...config.startup.mode === undefined ? {} : { mode: config.startup.mode }, ...(theme === undefined ? {} : { theme }) }
|
|
955
|
+
: { kind: 'fresh', ...config.startup.mode === undefined ? {} : { mode: config.startup.mode }, ...(theme === undefined ? {} : { theme }) }
|
|
956
|
+
// Read through the global service store, not the property proxy: appExit is
|
|
957
|
+
// an optional host value, never an injected dependency.
|
|
958
|
+
const exit = ctx.get('appExit')
|
|
959
|
+
if (exit === undefined) {
|
|
960
|
+
throw new Error('tui-runner: the launcher must provide ctx.appExit before the tree mounts')
|
|
961
|
+
}
|
|
962
|
+
const io: TuiIo = { mount: internals.mount, exit }
|
|
963
|
+
void run(ctx, startup, io).catch((error: unknown) => { fail(io, error) })
|
|
964
|
+
}
|